diff --git a/.gitignore b/.gitignore index 7c67e3e..e43ed2a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ hibiscus-txt # Testing data data/ config/log.txt -config/dev-config.txt \ No newline at end of file +config/dev-config.txt +config/style.css \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 40264af..b870f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ This file keeps track of changes in a human-readable fashion ## Upcoming -These changes were not yet released +These changes are not yet released + +* Fully refactored app internally * Adjusted default theme * Error pages are now translated diff --git a/go.mod b/go.mod index d798fb8..0316e99 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module hibiscus-txt +module git.a71.su/Andrew71/hibiscus-txt go 1.22 -require github.com/go-chi/chi/v5 v5.0.12 +require github.com/go-chi/chi/v5 v5.1.0 diff --git a/go.sum b/go.sum index bfc9174..7dfab1b 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= diff --git a/info.go b/info.go deleted file mode 100644 index 53fc8f5..0000000 --- a/info.go +++ /dev/null @@ -1,36 +0,0 @@ -package main - -import ( - "html/template" - "log/slog" - "net/http" -) - -var infoTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/info.html")) - -type AppInfo struct { - Version string - SourceLink string -} - -// Info contains app information. -var Info = AppInfo{ - Version: "1.1.4", - SourceLink: "https://git.a71.su/Andrew71/hibiscus", -} - -// GetInfo renders the info page. -func GetInfo(w http.ResponseWriter, r *http.Request) { - err := infoTemplate.ExecuteTemplate(w, "base", 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(Info.Version))) - w.WriteHeader(http.StatusOK) -} diff --git a/flags.go b/internal/app/flags.go similarity index 53% rename from flags.go rename to internal/app/flags.go index 921fa57..d11594e 100644 --- a/flags.go +++ b/internal/app/flags.go @@ -1,34 +1,37 @@ -package main +package app import ( "flag" "log" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "git.a71.su/Andrew71/hibiscus-txt/internal/logging" ) // FlagInit processes app flags. func FlagInit() { - config := flag.String("config", "", "override config file") + conf := flag.String("config", "", "override config file") username := flag.String("user", "", "override username") password := flag.String("pass", "", "override password") port := flag.Int("port", 0, "override port") debug := flag.Bool("debug", false, "debug logging") flag.Parse() - if *config != "" { - ConfigFile = *config - err := Cfg.Reload() + if *conf != "" { + config.ConfigFile = *conf + err := config.Cfg.Reload() if err != nil { log.Fatal(err) } } if *username != "" { - Cfg.Username = *username + config.Cfg.Username = *username } if *password != "" { - Cfg.Password = *password + config.Cfg.Password = *password } if *port != 0 { - Cfg.Port = *port + config.Cfg.Port = *port } - DebugMode = *debug + logging.DebugMode = *debug } diff --git a/internal/app/main.go b/internal/app/main.go new file mode 100644 index 0000000..774a9db --- /dev/null +++ b/internal/app/main.go @@ -0,0 +1,12 @@ +package app + +import ( + "git.a71.su/Andrew71/hibiscus-txt/internal/logging" + "git.a71.su/Andrew71/hibiscus-txt/internal/server" +) + +func Execute() { + FlagInit() + logging.LogInit() + server.Serve() +} diff --git a/internal/config/info.go b/internal/config/info.go new file mode 100644 index 0000000..e9fa4fe --- /dev/null +++ b/internal/config/info.go @@ -0,0 +1,12 @@ +package config + +type AppInfo struct { + Version string + SourceLink string +} + +// Info contains app information. +var Info = AppInfo{ + Version: "2.0.0", + SourceLink: "https://git.a71.su/Andrew71/hibiscus", +} \ No newline at end of file diff --git a/config.go b/internal/config/main.go similarity index 82% rename from config.go rename to internal/config/main.go index 0426092..0fe8397 100644 --- a/config.go +++ b/internal/config/main.go @@ -1,4 +1,4 @@ -package main +package config import ( "bufio" @@ -6,14 +6,17 @@ import ( "fmt" "log" "log/slog" - "net/http" "os" "reflect" "strconv" "strings" "time" + + "git.a71.su/Andrew71/hibiscus-txt/internal/files" + "git.a71.su/Andrew71/hibiscus-txt/internal/lang" ) +var Cfg = ConfigInit() var ConfigFile = "config/config.txt" type Config struct { @@ -143,40 +146,17 @@ func (c *Config) Reload() error { } slog.Debug("reloaded config", "config", c) - return SetLanguage(c.Language) // Load selected language + return lang.SetLanguage(c.Language) // Load selected language } // Read gets raw contents from ConfigFile. func (c *Config) Read() ([]byte, error) { - return ReadFile(ConfigFile) + return files.Read(ConfigFile) } // Save writes config's contents to the ConfigFile. func (c *Config) Save() error { - return SaveFile(ConfigFile, []byte(c.String())) -} - -// PostConfig calls PostEntry for config file, then reloads the config. -func PostConfig(w http.ResponseWriter, r *http.Request) { - PostEntry(ConfigFile, w, r) - err := 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 := 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) + return files.Save(ConfigFile, []byte(c.String())) } // ConfigInit loads config on startup. diff --git a/internal/config/time.go b/internal/config/time.go new file mode 100644 index 0000000..0dbc2b2 --- /dev/null +++ b/internal/config/time.go @@ -0,0 +1,31 @@ +package config + +import ( + "log/slog" + "time" +) + +// FIXME: This probably shouldn't be part of config package + +// Grace returns whether the grace period is active. +// NOTE: Grace period has minute precision +func (c Config) Grace() bool { + t := time.Now().In(c.Timezone) + active := (60*t.Hour() + t.Minute()) < int(c.GraceTime.Minutes()) + if active { + slog.Debug("grace period active", + "time", 60*t.Hour()+t.Minute(), + "grace", c.GraceTime.Minutes()) + } + return active +} + +// TodayDate returns today's formatted date. It accounts for Config.GraceTime. +func (c Config) TodayDate() string { + dateFormatted := time.Now().In(c.Timezone).Format(time.DateOnly) + if c.Grace() { + dateFormatted = time.Now().In(c.Timezone).AddDate(0, 0, -1).Format(time.DateOnly) + } + slog.Debug("today", "time", time.Now().In(c.Timezone).Format(time.DateTime)) + return dateFormatted +} diff --git a/export.go b/internal/files/export.go similarity index 95% rename from export.go rename to internal/files/export.go index 6a5a32a..2f206ec 100644 --- a/export.go +++ b/internal/files/export.go @@ -1,4 +1,4 @@ -package main +package files import ( "archive/zip" @@ -9,7 +9,7 @@ import ( "path/filepath" ) -var ExportPath = "data/export.zip" +var ExportPath = "data/export.zip" // TODO: Move to config // Export saves a .zip archive of the data folder to a file. func Export(filename string) error { diff --git a/files.go b/internal/files/files.go similarity index 59% rename from files.go rename to internal/files/files.go index 375c54c..7b7bda7 100644 --- a/files.go +++ b/internal/files/files.go @@ -1,4 +1,4 @@ -package main +package files import ( "bytes" @@ -8,7 +8,6 @@ import ( "path" "path/filepath" "strings" - "time" ) // DataFile modifies file path to ensure it's a .txt inside the data folder. @@ -16,8 +15,8 @@ func DataFile(filename string) string { return "data/" + path.Clean(filename) + ".txt" } -// ReadFile returns contents of a file. -func ReadFile(filename string) ([]byte, error) { +// Read returns contents of a file. +func Read(filename string) ([]byte, error) { if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) { return nil, err } @@ -29,8 +28,8 @@ func ReadFile(filename string) ([]byte, error) { return fileContents, nil } -// SaveFile Writes contents to a file. -func SaveFile(filename string, contents []byte) error { +// Save Writes contents to a file. +func Save(filename string, contents []byte) error { contents = bytes.TrimSpace(contents) if len(contents) == 0 { // Delete empty files err := os.Remove(filename) @@ -54,9 +53,9 @@ func SaveFile(filename string, contents []byte) error { return nil } -// ListFiles returns slice of filenames in a directory without extensions or path. +// List returns slice of filenames in a directory without extensions or path. // NOTE: What if I ever want to list non-text files or those outside data directory? -func ListFiles(directory string) ([]string, error) { +func List(directory string) ([]string, error) { filenames, err := filepath.Glob("data/" + path.Clean(directory) + "/*.txt") if err != nil { return nil, err @@ -67,25 +66,3 @@ func ListFiles(directory string) ([]string, error) { } return filenames, nil } - -// GraceActive returns whether the grace period (Cfg.GraceTime) is active. Grace period has minute precision -func GraceActive() bool { - t := time.Now().In(Cfg.Timezone) - active := (60*t.Hour() + t.Minute()) < int(Cfg.GraceTime.Minutes()) - if active { - slog.Debug("grace period active", - "time", 60*t.Hour()+t.Minute(), - "grace", Cfg.GraceTime.Minutes()) - } - return active -} - -// TodayDate returns today's formatted date. It accounts for Config.GraceTime. -func TodayDate() string { - dateFormatted := time.Now().In(Cfg.Timezone).Format(time.DateOnly) - if GraceActive() { - dateFormatted = time.Now().In(Cfg.Timezone).AddDate(0, 0, -1).Format(time.DateOnly) - } - slog.Debug("today", "time", time.Now().In(Cfg.Timezone).Format(time.DateTime)) - return dateFormatted -} diff --git a/i18n/en.json b/internal/lang/lang/en.json similarity index 100% rename from i18n/en.json rename to internal/lang/lang/en.json diff --git a/i18n/ru.json b/internal/lang/lang/ru.json similarity index 100% rename from i18n/ru.json rename to internal/lang/lang/ru.json diff --git a/i18n.go b/internal/lang/main.go similarity index 56% rename from i18n.go rename to internal/lang/main.go index fd97164..3647fc1 100644 --- a/i18n.go +++ b/internal/lang/main.go @@ -1,4 +1,4 @@ -package main +package lang import ( "embed" @@ -6,24 +6,24 @@ import ( "log/slog" ) -//go:embed i18n -var I18n embed.FS -var Translations = map[string]string{} +//go:embed lang +var lang embed.FS +var translations = map[string]string{} // SetLanguage loads a json file for selected language into the Translations map, with English language as a fallback. func SetLanguage(language string) error { loadLanguage := func(language string) error { - filename := "i18n/" + language + ".json" - fileContents, err := I18n.ReadFile(filename) + filename := "lang/" + language + ".json" + fileContents, err := lang.ReadFile(filename) if err != nil { slog.Error("error reading language file", "error", err, "file", filename) return err } - return json.Unmarshal(fileContents, &Translations) + return json.Unmarshal(fileContents, &translations) } - Translations = map[string]string{} // Clear the map to avoid previous language remaining + translations = map[string]string{} // Clear the map to avoid previous language remaining err := loadLanguage("en") // Load English as fallback if err != nil { return err @@ -31,9 +31,9 @@ func SetLanguage(language string) error { return loadLanguage(language) } -// TranslatableText attempts to match an id to a string in current language. -func TranslatableText(id string) string { - if v, ok := Translations[id]; !ok { +// Translate attempts to match an id to a string in current language. +func Translate(id string) string { + if v, ok := translations[id]; !ok { return id } else { return v diff --git a/logger.go b/internal/logging/logger.go similarity index 71% rename from logger.go rename to internal/logging/logger.go index 4633ac1..d004e50 100644 --- a/logger.go +++ b/internal/logging/logger.go @@ -1,11 +1,13 @@ -package main +package logging import ( - "github.com/go-chi/chi/v5/middleware" "io" "log" "log/slog" "os" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "github.com/go-chi/chi/v5/middleware" ) var DebugMode = false @@ -13,10 +15,10 @@ var DebugMode = false // LogInit makes slog output to both os.Stdout and a file if needed, and sets slog.LevelDebug if enabled. func LogInit() { var w io.Writer - if Cfg.LogToFile { - f, err := os.OpenFile(Cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if config.Cfg.LogToFile { + f, err := os.OpenFile(config.Cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) if err != nil { - slog.Error("error opening log file, logging to stdout", "path", Cfg.LogFile, "error", err) + slog.Error("error opening log file, logging to stdout only", "path", config.Cfg.LogFile, "error", err) return } // No defer f.Close() because that breaks the MultiWriter diff --git a/api.go b/internal/server/api.go similarity index 86% rename from api.go rename to internal/server/api.go index 6138cae..80cff1f 100644 --- a/api.go +++ b/internal/server/api.go @@ -1,13 +1,16 @@ -package main +package server import ( "encoding/json" "errors" - "github.com/go-chi/chi/v5" "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. @@ -19,7 +22,7 @@ func HandleWrite(_ int, err error) { // GetFileApi returns raw contents of a file. func GetFileApi(filename string, w http.ResponseWriter) { - fileContents, err := ReadFile(filename) + fileContents, err := files.Read(filename) if err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "file not found", http.StatusNotFound) @@ -39,7 +42,7 @@ func PostFileApi(filename string, w http.ResponseWriter, r *http.Request) { HandleWrite(w.Write([]byte("error reading body"))) return } - err = SaveFile(filename, body) + err = files.Save(filename, body) if err != nil { w.WriteHeader(http.StatusInternalServerError) HandleWrite(w.Write([]byte("error saving file"))) @@ -51,7 +54,7 @@ func PostFileApi(filename string, w http.ResponseWriter, r *http.Request) { // GetFileList returns JSON list of filenames in a directory without extensions or path. func GetFileList(directory string, w http.ResponseWriter) { - filenames, err := ListFiles(directory) + filenames, err := files.List(directory) if err != nil { http.Error(w, "error searching for files", http.StatusInternalServerError) return @@ -72,7 +75,7 @@ func GetDayApi(w http.ResponseWriter, r *http.Request) { HandleWrite(w.Write([]byte("day not specified"))) return } - GetFileApi(DataFile("day/"+dayString), w) + GetFileApi(files.DataFile("day/"+dayString), w) } // GetNoteApi returns contents of a note specified in URL. @@ -83,7 +86,7 @@ func GetNoteApi(w http.ResponseWriter, r *http.Request) { HandleWrite(w.Write([]byte("note not specified"))) return } - GetFileApi(DataFile("notes/"+noteString), w) + GetFileApi(files.DataFile("notes/"+noteString), w) } // PostNoteApi writes contents of Request.Body to a note specified in URL. @@ -94,15 +97,16 @@ func PostNoteApi(w http.ResponseWriter, r *http.Request) { HandleWrite(w.Write([]byte("note not specified"))) return } - PostFileApi(DataFile("notes/"+noteString), w, r) + 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 GraceActive() { + if config.Cfg.Grace() { value = "true" } HandleWrite(w.Write([]byte(value))) w.WriteHeader(http.StatusOK) } + diff --git a/auth.go b/internal/server/auth.go similarity index 77% rename from auth.go rename to internal/server/auth.go index 0f2aee7..8028124 100644 --- a/auth.go +++ b/internal/server/auth.go @@ -1,4 +1,4 @@ -package main +package server import ( "crypto/sha256" @@ -9,6 +9,9 @@ import ( "os" "strings" "time" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "git.a71.su/Andrew71/hibiscus-txt/internal/lang" ) type failedLogin struct { @@ -22,7 +25,7 @@ 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(TranslatableText("info.telegram.auth_fail")+":\nusername=%s\npassword=%s\nremote=%s", username, password, 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} @@ -34,7 +37,7 @@ func NoteLoginFail(username string, password string, r *http.Request) { failedLogins = updatedLogins // At least 3 failed attempts in last 100 seconds -> likely bruteforce - if len(failedLogins) >= 3 && Cfg.Scram { + if len(failedLogins) >= 3 && config.Cfg.Scram { Scram() } } @@ -49,8 +52,8 @@ func BasicAuth(next http.Handler) http.Handler { // Calculate SHA-256 hashes for equal length in ConstantTimeCompare usernameHash := sha256.Sum256([]byte(username)) passwordHash := sha256.Sum256([]byte(password)) - expectedUsernameHash := sha256.Sum256([]byte(Cfg.Username)) - expectedPasswordHash := sha256.Sum256([]byte(Cfg.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 @@ -72,22 +75,22 @@ func BasicAuth(next http.Handler) http.Handler { // Scram shuts down the service, useful in case of suspected attack. func Scram() { slog.Warn("SCRAM triggered, shutting down") - NotifyTelegram(TranslatableText("info.telegram.scram")) + NotifyTelegram(lang.Translate("info.telegram.scram")) os.Exit(0) } // NotifyTelegram attempts to send a message to admin through Telegram. func NotifyTelegram(msg string) { - if Cfg.TelegramChat == "" || Cfg.TelegramToken == "" { + if config.Cfg.TelegramChat == "" || config.Cfg.TelegramToken == "" { slog.Debug("ignoring telegram request due to lack of credentials") return } client := &http.Client{} - data := "chat_id=" + Cfg.TelegramChat + "&text=" + msg - if Cfg.TelegramTopic != "" { - data += "&message_thread_id=" + Cfg.TelegramTopic + 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"+Cfg.TelegramToken+"/sendMessage", strings.NewReader(data)) + 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 diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000..2a421a6 --- /dev/null +++ b/internal/server/config.go @@ -0,0 +1,31 @@ +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/days.go b/internal/server/days.go new file mode 100644 index 0000000..980c02c --- /dev/null +++ b/internal/server/days.go @@ -0,0 +1,65 @@ +package server + +import ( + "html/template" + "net/http" + "strings" + "time" + + "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" +) + +// 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 { + var filesFormatted []Entry + for i := range files { + v := files[len(files)-1-i] // This is suboptimal, but reverse order is better here + dayString := v + t, err := time.Parse(time.DateOnly, v) + if err == nil { + dayString = t.Format("02 Jan 2006") + } + + // Fancy text for today and tomorrow + // This looks bad, but strings.Title is deprecated, and I'm not importing a golang.org/x package for this... + // (chances we ever run into tomorrow are really low) + if v == config.Cfg.TodayDate() { + dayString = lang.Translate("link.today") + dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:]) + } else if v > config.Cfg.TodayDate() { + dayString = lang.Translate("link.tomorrow") + dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:]) + } + filesFormatted = append(filesFormatted, Entry{Title: dayString, Link: "day/" + v}) + } + return filesFormatted + }) +} + +// 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"))) + return + } + if dayString == config.Cfg.TodayDate() { // Today can still be edited + http.Redirect(w, r, "/", http.StatusFound) + return + } + + title := dayString + t, err := time.Parse(time.DateOnly, dayString) + if err == nil { // This is low priority so silently fail + title = t.Format("02 Jan 2006") + } + + GetEntry(w, r, title, files.DataFile("day/"+dayString), false) +} diff --git a/internal/server/entries.go b/internal/server/entries.go new file mode 100644 index 0000000..ebad8a7 --- /dev/null +++ b/internal/server/entries.go @@ -0,0 +1,80 @@ +package server + +import ( + "errors" + "html/template" + "log/slog" + "net/http" + "os" + + "git.a71.su/Andrew71/hibiscus-txt/internal/files" + "git.a71.su/Andrew71/hibiscus-txt/internal/templates" +) + +type EntryList struct { + Title string + Description template.HTML + Entries []Entry +} + +type Entry struct { + Title string + Content string + Link string +} + +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) { + filesList, err := files.List(dir) + if err != nil { + slog.Error("error reading file list", "directory", dir, "error", err) + InternalError(w, r) + return + } + var filesFormatted = format(filesList) + + err = templates.List.ExecuteTemplate(w, "base", EntryList{Title: title, Description: description, Entries: filesFormatted}) + if err != nil { + slog.Error("error executing template", "error", err) + InternalError(w, r) + return + } +} + +// 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) { + entry = []byte("") + } else { + slog.Error("error reading entry file", "error", err, "file", filename) + InternalError(w, r) + return + } + } + + if editable { + err = templates.Edit.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)}) + } else { + err = templates.View.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)}) + } + if err != nil { + InternalError(w, r) + return + } +} + +// 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) + } + if r.Referer() != "" { + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusFound) + return + } +} diff --git a/internal/server/errors.go b/internal/server/errors.go new file mode 100644 index 0000000..aab5237 --- /dev/null +++ b/internal/server/errors.go @@ -0,0 +1,32 @@ +package server + +import ( + "log/slog" + "net/http" + + "git.a71.su/Andrew71/hibiscus-txt/internal/templates" +) + +// NotFound returns a user-friendly 404 error page. +func NotFound(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + + err := templates.Template404.Execute(w, nil) + if err != nil { + slog.Error("error rendering error 404 page", "error", err) + InternalError(w, r) + return + } +} + +// InternalError returns a user-friendly 500 error page. +func InternalError(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + + 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."))) + return + } +} diff --git a/internal/server/info.go b/internal/server/info.go new file mode 100644 index 0000000..9cfd09d --- /dev/null +++ b/internal/server/info.go @@ -0,0 +1,25 @@ +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/notes.go b/internal/server/notes.go new file mode 100644 index 0000000..0bad17e --- /dev/null +++ b/internal/server/notes.go @@ -0,0 +1,54 @@ +package server + +import ( + "html/template" + "net/http" + "net/url" + + "git.a71.su/Andrew71/hibiscus-txt/internal/files" + "git.a71.su/Andrew71/hibiscus-txt/internal/lang" + "github.com/go-chi/chi/v5" +) + +// 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 { + var filesFormatted []Entry + for _, v := range files { + // titleString := strings.Replace(v, "-", " ", -1) // This would be cool, but what if I need a hyphen? + filesFormatted = append(filesFormatted, Entry{Title: v, Link: "notes/" + v}) + } + return filesFormatted + }) +} + +// 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"))) + return + } + // Handle non-latin note names + if decodedNote, err := url.QueryUnescape(noteString); err == nil { + noteString = decodedNote + } + + GetEntry(w, r, noteString, files.DataFile("notes/"+noteString), true) +} + +// 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"))) + return + } + PostEntry(files.DataFile("notes/"+noteString), w, r) +} diff --git a/public/favicon-512.png b/internal/server/public/favicon-512.png similarity index 100% rename from public/favicon-512.png rename to internal/server/public/favicon-512.png diff --git a/public/favicon.ico b/internal/server/public/favicon.ico similarity index 100% rename from public/favicon.ico rename to internal/server/public/favicon.ico diff --git a/public/main.css b/internal/server/public/main.css similarity index 100% rename from public/main.css rename to internal/server/public/main.css diff --git a/public/main.js b/internal/server/public/main.js similarity index 100% rename from public/main.js rename to internal/server/public/main.js diff --git a/public/manifest.json b/internal/server/public/manifest.json similarity index 100% rename from public/manifest.json rename to internal/server/public/manifest.json diff --git a/public/themes/gruvbox.css b/internal/server/public/themes/gruvbox.css similarity index 100% rename from public/themes/gruvbox.css rename to internal/server/public/themes/gruvbox.css diff --git a/public/themes/high-contrast.css b/internal/server/public/themes/high-contrast.css similarity index 100% rename from public/themes/high-contrast.css rename to internal/server/public/themes/high-contrast.css diff --git a/public/themes/lavender.css b/internal/server/public/themes/lavender.css similarity index 100% rename from public/themes/lavender.css rename to internal/server/public/themes/lavender.css diff --git a/public/themes/sans.css b/internal/server/public/themes/sans.css similarity index 100% rename from public/themes/sans.css rename to internal/server/public/themes/sans.css diff --git a/serve.go b/internal/server/serve.go similarity index 66% rename from serve.go rename to internal/server/serve.go index 68278d4..7691f73 100644 --- a/serve.go +++ b/internal/server/serve.go @@ -1,14 +1,24 @@ -package main +package server import ( - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" + "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() @@ -20,18 +30,20 @@ func Serve() { userRouter := chi.NewRouter() userRouter.Use(BasicAuth) userRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { - GetEntry(w, r, TranslatableText("title.today"), DataFile("day/"+TodayDate()), true) + GetEntry(w, r, lang.Translate("title.today"), files.DataFile("day/"+config.Cfg.TodayDate()), true) }) - userRouter.Post("/", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) }) + 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", DataFile("readme"), true) }) - userRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("readme"), w, r) }) - userRouter.Get("/config", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "config.txt", ConfigFile, true) }) + 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) @@ -45,19 +57,23 @@ func Serve() { 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(DataFile("day/"+TodayDate()), w) }) - apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) }) - apiRouter.Get("/export", GetExport) + 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)) + fs := http.FileServer(http.FS(public)) r.Handle("/public/*", fs) - slog.Info("🌺 Website working", "port", Cfg.Port) + slog.Info("🌺 Website working", "port", config.Cfg.Port) slog.Debug("Debug mode enabled") - log.Fatal(http.ListenAndServe(":"+strconv.Itoa(Cfg.Port), r)) + log.Fatal(http.ListenAndServe(":"+strconv.Itoa(config.Cfg.Port), r)) } diff --git a/internal/templates/main.go b/internal/templates/main.go new file mode 100644 index 0000000..50685bf --- /dev/null +++ b/internal/templates/main.go @@ -0,0 +1,38 @@ +package templates + +import ( + "embed" + "html/template" + "log/slog" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "git.a71.su/Andrew71/hibiscus-txt/internal/lang" +) + +// pages contains the HTML templates used by the app. +// +//go:embed pages +var pages embed.FS + +// EmbeddedPage returns contents of a file in Pages while "handling" potential errors. +func EmbeddedPage(name string) []byte { + data, err := pages.ReadFile(name) + if err != nil { + slog.Error("error reading embedded file", "err", err) + } + return data +} + +var templateFuncs = map[string]interface{}{ + "translate": lang.Translate, + "info": func() config.AppInfo { return config.Info }, + "config": func() config.Config { return config.Cfg }, +} +var Edit = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/edit.html")) +var View = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/entry.html")) +var List = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/list.html")) + +var Info = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/info.html")) + +var Template404 = template.Must(template.New("404").Funcs(templateFuncs).ParseFS(pages, "pages/error/404.html")) +var Template500 = template.Must(template.New("500").Funcs(templateFuncs).ParseFS(pages, "pages/error/500.html")) diff --git a/pages/base.html b/internal/templates/pages/base.html similarity index 69% rename from pages/base.html rename to internal/templates/pages/base.html index d1f0f67..a74d7bf 100644 --- a/pages/base.html +++ b/internal/templates/pages/base.html @@ -1,13 +1,13 @@ {{ define "header" }}

{{ config.Title }}

-

{{ translatableText "time.date" }} a place

+

{{ translate "time.date" }} a place

{{ end }} {{- define "base" -}} - + @@ -35,7 +35,7 @@ {{ define "footer" }} {{ end }} \ No newline at end of file diff --git a/pages/edit.html b/internal/templates/pages/edit.html similarity index 72% rename from pages/edit.html rename to internal/templates/pages/edit.html index 0598395..bd91ed2 100644 --- a/pages/edit.html +++ b/internal/templates/pages/edit.html @@ -2,6 +2,6 @@

- +
{{ end }} \ No newline at end of file diff --git a/pages/entry.html b/internal/templates/pages/entry.html similarity index 100% rename from pages/entry.html rename to internal/templates/pages/entry.html diff --git a/pages/error/404.html b/internal/templates/pages/error/404.html similarity index 78% rename from pages/error/404.html rename to internal/templates/pages/error/404.html index 48af44e..3d55f49 100644 --- a/pages/error/404.html +++ b/internal/templates/pages/error/404.html @@ -11,8 +11,8 @@

Error 404 - Not Found

-

{{ translatableText "error.404" }}

-

{{ translatableText "error.prompt" }}

+

{{ translate "error.404" }}

+

{{ translate "error.prompt" }}

diff --git a/pages/error/500.html b/internal/templates/pages/error/500.html similarity index 78% rename from pages/error/500.html rename to internal/templates/pages/error/500.html index 4da0874..8c5220e 100644 --- a/pages/error/500.html +++ b/internal/templates/pages/error/500.html @@ -11,8 +11,8 @@

Error 500 - Internal Server Error

-

{{ translatableText "error.500" }}

-

{{ translatableText "error.prompt" }}

+

{{ translate "error.500" }}

+

{{ translate "error.prompt" }}

diff --git a/internal/templates/pages/info.html b/internal/templates/pages/info.html new file mode 100644 index 0000000..8143709 --- /dev/null +++ b/internal/templates/pages/info.html @@ -0,0 +1,9 @@ +{{ define "main" }} +

{{ translate "title.info" }}

+ +{{ end }} \ No newline at end of file diff --git a/pages/list.html b/internal/templates/pages/list.html similarity index 100% rename from pages/list.html rename to internal/templates/pages/list.html diff --git a/main.go b/main.go index 9b4926e..903adc2 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,7 @@ package main -var Cfg = ConfigInit() +import "git.a71.su/Andrew71/hibiscus-txt/internal/app" func main() { - FlagInit() - LogInit() - Serve() -} + app.Execute() +} \ No newline at end of file diff --git a/pages/info.html b/pages/info.html deleted file mode 100644 index 584e8e4..0000000 --- a/pages/info.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ define "main" }} -

{{ translatableText "title.info" }}

- -{{ end }} \ No newline at end of file diff --git a/routes.go b/routes.go deleted file mode 100644 index 237b089..0000000 --- a/routes.go +++ /dev/null @@ -1,234 +0,0 @@ -package main - -import ( - "embed" - "errors" - "html/template" - "log/slog" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/go-chi/chi/v5" -) - -type EntryList struct { - Title string - Description template.HTML - Entries []Entry -} - -type Entry struct { - Title string - Content string - Link string -} - -type formatEntries func([]string) []Entry - -// Public contains the static files e.g. CSS, JS. -// -//go:embed public -var Public embed.FS - -// Pages contains the HTML templates used by the app. -// -//go:embed pages -var Pages embed.FS - -// EmbeddedPage returns contents of a file in Pages while "handling" potential errors. -func EmbeddedPage(name string) []byte { - data, err := Pages.ReadFile(name) - if err != nil { - slog.Error("error reading embedded file", "err", err) - } - return data -} - -var templateFuncs = map[string]interface{}{ - "translatableText": TranslatableText, - "info": func() AppInfo { return Info }, - "config": func() Config { return Cfg }, -} -var editTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/edit.html")) -var viewTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/entry.html")) -var listTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/list.html")) - -var template404 = template.Must(template.New("404").Funcs(templateFuncs).ParseFS(Pages, "pages/error/404.html")) - -// NotFound returns a user-friendly 404 error page. -func NotFound(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(404) - - err := template404.Execute(w, nil) - if err != nil { - slog.Error("error rendering error 404 page", "error", err) - InternalError(w, r) - return - } -} - -var template500 = template.Must(template.New("500").Funcs(templateFuncs).ParseFS(Pages, "pages/error/500.html")) - -// InternalError returns a user-friendly 500 error page. -func InternalError(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(500) - - err := 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."))) - return - } -} - -// GetEntries handles showing a list. -func GetEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) { - filesList, err := ListFiles(dir) - if err != nil { - slog.Error("error reading file list", "directory", dir, "error", err) - InternalError(w, r) - return - } - var filesFormatted = format(filesList) - - err = listTemplate.ExecuteTemplate(w, "base", EntryList{Title: title, Description: description, Entries: filesFormatted}) - if err != nil { - slog.Error("error executing template", "error", err) - InternalError(w, r) - return - } -} - -// GetDays calls GetEntries for previous days' entries. -func GetDays(w http.ResponseWriter, r *http.Request) { - description := template.HTML( - "" + template.HTMLEscapeString(TranslatableText("prompt.days")) + "") - GetEntries(w, r, TranslatableText("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 - dayString := v - t, err := time.Parse(time.DateOnly, v) - if err == nil { - dayString = t.Format("02 Jan 2006") - } - - // Fancy text for today and tomorrow - // This looks bad, but strings.Title is deprecated, and I'm not importing a golang.org/x package for this... - // (chances we ever run into tomorrow are really low) - if v == TodayDate() { - dayString = TranslatableText("link.today") - dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:]) - } else if v > TodayDate() { - dayString = TranslatableText("link.tomorrow") - dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:]) - } - filesFormatted = append(filesFormatted, Entry{Title: dayString, Link: "day/" + v}) - } - return filesFormatted - }) -} - -// 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(TranslatableText("button.notes")) + "" + - " ") - GetEntries(w, r, TranslatableText("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? - filesFormatted = append(filesFormatted, Entry{Title: v, Link: "notes/" + v}) - } - return filesFormatted - }) -} - -// 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 := ReadFile(filename) - if err != nil { - if editable && errors.Is(err, os.ErrNotExist) { - entry = []byte("") - } else { - slog.Error("error reading entry file", "error", err, "file", filename) - InternalError(w, r) - return - } - } - - if editable { - err = editTemplate.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)}) - } else { - err = viewTemplate.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)}) - } - if err != nil { - InternalError(w, r) - return - } -} - -// 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 := SaveFile(filename, []byte(r.FormValue("text"))) - if err != nil { - slog.Error("error saving file", "error", err, "file", filename) - } - if r.Referer() != "" { - http.Redirect(w, r, r.Header.Get("Referer"), http.StatusFound) - return - } -} - -// 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"))) - return - } - if dayString == TodayDate() { // Today can still be edited - http.Redirect(w, r, "/", http.StatusFound) - return - } - - title := dayString - t, err := time.Parse(time.DateOnly, dayString) - if err == nil { // This is low priority so silently fail - title = t.Format("02 Jan 2006") - } - - GetEntry(w, r, title, DataFile("day/"+dayString), false) -} - -// 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"))) - return - } - // Handle non-latin note names - if decodedNote, err := url.QueryUnescape(noteString); err == nil { - noteString = decodedNote - } - - GetEntry(w, r, noteString, DataFile("notes/"+noteString), true) -} - -// 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"))) - return - } - PostEntry(DataFile("notes/"+noteString), w, r) -}