Error 404 - Not Found
-{{ translatableText "error.404" }}
-{{ translatableText "error.prompt" }}
+{{ translate "error.404" }}
+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" }}
{{ translatableText "time.date" }} a place ({{ translatableText "time.grace" }}) {{ translate "time.date" }} a place ({{ translate "time.grace" }}){{ config.Title }}
-
{{ translatableText "error.404" }}
-{{ translatableText "error.prompt" }}
+{{ translate "error.404" }}
+{{ translatableText "error.500" }}
-{{ translatableText "error.prompt" }}
+{{ translate "error.500" }}
+