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..2324375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,20 @@ # Changelog This file keeps track of changes in a human-readable fashion -## Upcoming -These changes were not yet released +## v2.0.0 stuff + +## Upcoming + +These changes are not yet released + +* Fully refactored the project internally to use several packages +* Log *directory* is now specified as opposed to *file*. +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 +* Disabling JS now makes the status bar disappear since it won't work ## v1.1.4 diff --git a/README.md b/README.md index de0c9b6..78fd897 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Simple plaintext diary. This project is *very* opinionated and minimal, and is designed primarily for my usage. -As a result, I can't guarantee that it's either secure or stable. +As a result, neither security nor stability are guaranteed. ## Features: diff --git a/TODO.md b/TODO.md index 1245c97..87335e5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,12 @@ # TODO List of things to add to this project -## Urgent (1.1.5-2.0.0) +## Priority 2.0.0 +* (pre-release) re-write README * `style.css` in config instead of theme (provide themes as examples in repo) * Auth improvement so it DOESN'T ASK ME FOR PASSWORD EVERY DAY UGH XD +* Specify data directory in config or with flags +* Configure more of the app with flags, possibly move to `cobra` for this ## Nice to have * Forward/backward buttons for days diff --git a/api.go b/api.go deleted file mode 100644 index 6138cae..0000000 --- a/api.go +++ /dev/null @@ -1,108 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "github.com/go-chi/chi/v5" - "io" - "log/slog" - "net/http" - "os" -) - -// 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 := ReadFile(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 = SaveFile(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 := ListFiles(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(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(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(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() { - value = "true" - } - HandleWrite(w.Write([]byte(value))) - w.WriteHeader(http.StatusOK) -} diff --git a/auth.go b/auth.go deleted file mode 100644 index 0f2aee7..0000000 --- a/auth.go +++ /dev/null @@ -1,105 +0,0 @@ -package main - -import ( - "crypto/sha256" - "crypto/subtle" - "fmt" - "log/slog" - "net/http" - "os" - "strings" - "time" -) - -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(TranslatableText("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 && 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(Cfg.Username)) - expectedPasswordHash := sha256.Sum256([]byte(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(TranslatableText("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 == "" { - 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 - } - req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+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/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..45383ff --- /dev/null +++ b/internal/config/info.go @@ -0,0 +1,22 @@ +package config + +type AppInfo struct { + version string + source string +} + +// Info contains app information. +var Info = AppInfo{ + version: "2.0.0", + source: "https://git.a71.su/Andrew71/hibiscus", +} + +// Version returns the current app version +func (i AppInfo) Version() string { + return i.version +} + +// Source returns app's git repository +func (i AppInfo) Source() string { + return i.source +} \ No newline at end of file diff --git a/config.go b/internal/config/main.go similarity index 78% rename from config.go rename to internal/config/main.go index 0426092..d0241b2 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 { @@ -26,7 +29,7 @@ type Config struct { Theme string `config:"theme" type:"string"` Title string `config:"title" type:"string"` LogToFile bool `config:"log_to_file" type:"bool"` - LogFile string `config:"log_file" type:"string"` + LogDir string `config:"log_dir" type:"string"` Scram bool `config:"enable_scram" type:"bool"` TelegramToken string `config:"tg_token" type:"string"` @@ -34,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, @@ -44,7 +47,7 @@ var DefaultConfig = Config{ Theme: "", Title: "🌺 Hibiscus.txt", LogToFile: false, - LogFile: "config/log.txt", + LogDir: "logs", Scram: false, TelegramToken: "", @@ -56,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") @@ -72,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() @@ -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/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..bec934c --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,51 @@ +package logging + +import ( + "io" + "log" + "log/slog" + "os" + "path" + "time" + + "git.a71.su/Andrew71/hibiscus-txt/internal/config" + "github.com/go-chi/chi/v5/middleware" +) + +var DebugMode = false + +// file returns the appropriate filename for log +// (log_dir/hibiscus_YYYY-MM-DD_HH:MM:SS.log) +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() + var w io.Writer = os.Stdout + if config.Cfg.LogToFile { + // Create dir in case it doesn't exist yet to avoid errors + err := os.MkdirAll(path.Dir(logFile), 0755) + if err != nil { + slog.Error("error creating log dir, logging to stdout only", "path", path.Dir(logFile), "error", err) + } else { + f, err := os.OpenFile(logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + slog.Error("error opening log file, logging to stdout only", "path", logFile, "error", err) + return + } + // No defer f.Close() because that breaks the MultiWriter + w = io.MultiWriter(f, os.Stdout) + } + } + + // Make slog and chi use intended format + var opts *slog.HandlerOptions + if DebugMode { + opts = &slog.HandlerOptions{Level: slog.LevelDebug} + } + 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 +} 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/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/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/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 96% rename from public/main.js rename to internal/server/public/main.js index 279b691..96cba24 100644 --- a/public/main.js +++ b/internal/server/public/main.js @@ -45,6 +45,6 @@ function sanitize(title) { // Open a new note function newNote(text_prompt) { - name = sanitize(prompt(text_prompt + ':')) + let name = sanitize(prompt(text_prompt + ':')) window.location.replace('/notes/' + name) } \ No newline at end of file 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/internal/server/routes/days.go b/internal/server/routes/days.go new file mode 100644 index 0000000..75f4fe4 --- /dev/null +++ b/internal/server/routes/days.go @@ -0,0 +1,66 @@ +package routes + +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" + "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) { + 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) + util.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/routes/entries.go b/internal/server/routes/entries.go new file mode 100644 index 0000000..e11b6d5 --- /dev/null +++ b/internal/server/routes/entries.go @@ -0,0 +1,80 @@ +package routes + +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/routes/errors.go b/internal/server/routes/errors.go new file mode 100644 index 0000000..91bf00d --- /dev/null +++ b/internal/server/routes/errors.go @@ -0,0 +1,32 @@ +package routes + +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) + 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/routes/notes.go b/internal/server/routes/notes.go new file mode 100644 index 0000000..b5b8a19 --- /dev/null +++ b/internal/server/routes/notes.go @@ -0,0 +1,55 @@ +package routes + +import ( + "html/template" + "net/http" + "net/url" + + "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) { + // 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) + util.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) + util.HandleWrite(w.Write([]byte("note not specified"))) + return + } + postEntry(files.DataFile("notes/"+noteString), w, 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 new file mode 100644 index 0000000..e349a19 --- /dev/null +++ b/internal/templates/main.go @@ -0,0 +1,39 @@ +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 []byte("") + } + 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 67% rename from pages/base.html rename to internal/templates/pages/base.html index d1f0f67..12f50ff 100644 --- a/pages/base.html +++ b/internal/templates/pages/base.html @@ -1,13 +1,13 @@ {{ define "header" }}

{{ config.Title }}

-

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

+
{{ end }} {{- define "base" -}} - + @@ -25,6 +25,7 @@ {{- template "footer" . -}}