Add config editing to UI

This commit is contained in:
Andrew-71 2024-05-09 23:44:37 +03:00
parent 20ca509a2b
commit fa2ecacce5
15 changed files with 74 additions and 55 deletions

View file

@ -1,6 +1,13 @@
# Changelog # Changelog
This file keeps track of changes in more human-readable fashion This file keeps track of changes in more human-readable fashion
## v0.6.0
* Replaced config reload with edit in info (api method still available, config reloads on save)
* Bug fixes
* Filenames are now sanitized when writing files
* "Tomorrow" in days list is now also displayed if Timezone is changed and grace period is off
* Frontend date display now uses configured timezone
## v0.5.0 ## v0.5.0
* Added a JS prompt to create new note * Added a JS prompt to create new note
* "Sanitization" for this method is basic and assumes a well-meaning user * "Sanitization" for this method is basic and assumes a well-meaning user

View file

@ -52,6 +52,7 @@ timezone=Local # IANA Time zone database identifier ("UTC", Local", "Europe/Mos
grace_period=0s # Time after a new day begins, but before the switch to next day's file. e.g. 2h30m - files will change at 2:30am grace_period=0s # Time after a new day begins, but before the switch to next day's file. e.g. 2h30m - files will change at 2:30am
language=en # ISO-639 language code (pre-installed - en, ru) language=en # ISO-639 language code (pre-installed - en, ru)
theme=default # Picked theme (pre-installed - default, gruvbox, high-contrast) theme=default # Picked theme (pre-installed - default, gruvbox, high-contrast)
title=🌺 Hibiscus.txt # The text in the header
log_to_file=false # Whether to write logs to a file log_to_file=false # Whether to write logs to a file
log_file=config/log.txt # Where to store the log file if it is enabled log_file=config/log.txt # Where to store the log file if it is enabled
enable_scram=false # Whether the app should shut down if there are 3 or more failed login attempts within 100 seconds enable_scram=false # Whether the app should shut down if there are 3 or more failed login attempts within 100 seconds

11
TODO.md
View file

@ -3,22 +3,13 @@ List of things to add to this project
## v1.0.0 ## v1.0.0
* a logo so I can enable PWA (and look cool) * a logo so I can enable PWA (and look cool)
* Themes
* Theme in config [X]
* Themes
* Default [X]
* Gruvbox [X]
* High contrast [ ]
* Seasons?
* Check for bugs
* Versioned containers via `ghcr.io` or `dockerhub`, * Versioned containers via `ghcr.io` or `dockerhub`,
with automatic CI/CD build on release with automatic CI/CD build on release
* Test test test !!!! * ...QA? And polishing.
## Brainstorming ## Brainstorming
Don't expect any of this, these are ideas floating inside my head Don't expect any of this, these are ideas floating inside my head
* Further info page functionality * Further info page functionality
* Edit config
* Statistics e.g. mb taken, number of day pages/notes * Statistics e.g. mb taken, number of day pages/notes
* Test telegram link * Test telegram link
* Multi-user support through several username-pass keys * Multi-user support through several username-pass keys

10
api.go
View file

@ -72,17 +72,17 @@ func GetDayApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("day not specified"))) HandleWrite(w.Write([]byte("day not specified")))
return return
} }
GetFile("day/"+dayString, w) GetFile(DataFile("day/"+dayString), w)
} }
// GetTodayApi runs GetFile with today's date as filename // GetTodayApi runs GetFile with today's date as filename
func GetTodayApi(w http.ResponseWriter, _ *http.Request) { func GetTodayApi(w http.ResponseWriter, _ *http.Request) {
GetFile("day/"+TodayDate(), w) GetFile(DataFile("day/"+TodayDate()), w)
} }
// PostTodayApi runs PostFile with today's date as filename // PostTodayApi runs PostFile with today's date as filename
func PostTodayApi(w http.ResponseWriter, r *http.Request) { func PostTodayApi(w http.ResponseWriter, r *http.Request) {
PostFile("day/"+TodayDate(), w, r) PostFile(DataFile("day/"+TodayDate()), w, r)
} }
// GetNoteApi returns contents of a note specified in URL // GetNoteApi returns contents of a note specified in URL
@ -93,7 +93,7 @@ func GetNoteApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified"))) HandleWrite(w.Write([]byte("note not specified")))
return return
} }
GetFile("notes/"+noteString, w) GetFile(DataFile("notes/"+noteString), w)
} }
// PostNoteApi writes request's body contents to a note specified in URL // PostNoteApi writes request's body contents to a note specified in URL
@ -104,7 +104,7 @@ func PostNoteApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified"))) HandleWrite(w.Write([]byte("note not specified")))
return return
} }
PostFile("notes/"+noteString, w, r) PostFile(DataFile("notes/"+noteString), w, r)
} }
// GraceActiveApi returns "true" if grace period is active, and "false" otherwise // GraceActiveApi returns "true" if grace period is active, and "false" otherwise

View file

@ -49,10 +49,9 @@ var DefaultConfig = Config{
TelegramChat: "", TelegramChat: "",
} }
// Save puts modified and mandatory config options into the config.txt file // String returns text version of modified and mandatory config options
func (c *Config) Save() error { func (c *Config) String() string {
output := "" output := ""
v := reflect.ValueOf(*c) v := reflect.ValueOf(*c)
vDefault := reflect.ValueOf(DefaultConfig) vDefault := reflect.ValueOf(DefaultConfig)
typeOfS := v.Type() typeOfS := v.Type()
@ -64,22 +63,14 @@ func (c *Config) Save() error {
output += fmt.Sprintf("%s=%v\n", key, value) output += fmt.Sprintf("%s=%v\n", key, value)
} }
} }
return output
f, err := os.OpenFile(ConfigFile, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
if _, err := f.Write([]byte(output)); err != nil {
return err
}
return nil
} }
func (c *Config) Reload() error { func (c *Config) Reload() error {
*c = DefaultConfig // Reset config *c = DefaultConfig // Reset config
if _, err := os.Stat(ConfigFile); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(ConfigFile); errors.Is(err, os.ErrNotExist) {
err := c.Save() err := c.Save([]byte(c.String()))
if err != nil { if err != nil {
return err return err
} }
@ -156,6 +147,16 @@ func (c *Config) Reload() error {
return LoadLanguage(c.Language) // Load selected language return LoadLanguage(c.Language) // Load selected language
} }
// Read gets raw contents from ConfigFile
func (c *Config) Read() ([]byte, error) {
return ReadFile(ConfigFile)
}
// Save writes to ConfigFile
func (c *Config) Save(contents []byte) error {
return SaveFile(ConfigFile, contents)
}
// ConfigInit loads config on startup // ConfigInit loads config on startup
func ConfigInit() Config { func ConfigInit() Config {
cfg := Config{} cfg := Config{}

View file

@ -2,9 +2,4 @@ username=admin
password=admin password=admin
port=7101 port=7101
timezone=Local timezone=Local
grace_period=0s
language=en language=en
theme=default
log_to_file=false
log_file=config/log.txt
enable_scram=false

View file

@ -11,14 +11,16 @@ import (
"time" "time"
) )
// DataFile modifies file path to ensure it's a .txt inside the data folder
func DataFile(filename string) string {
return "data/" + path.Clean(filename) + ".txt"
}
// ReadFile returns raw contents of a file // ReadFile returns raw contents of a file
func ReadFile(filename string) ([]byte, error) { func ReadFile(filename string) ([]byte, error) {
filename = "data/" + path.Clean(filename) + ".txt" // Does this sanitize the path?
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
return nil, err return nil, err
} }
fileContents, err := os.ReadFile(filename) fileContents, err := os.ReadFile(filename)
if err != nil { if err != nil {
slog.Error("error reading file", slog.Error("error reading file",
@ -26,14 +28,12 @@ func ReadFile(filename string) ([]byte, error) {
"file", filename) "file", filename)
return nil, err return nil, err
} }
return fileContents, nil return fileContents, nil
} }
// SaveFile Writes request's contents to a file // SaveFile Writes contents to a file
func SaveFile(filename string, contents []byte) error { func SaveFile(filename string, contents []byte) error {
contents = bytes.TrimSpace(contents) contents = bytes.TrimSpace(contents)
filename = "data/" + filename + ".txt"
if len(contents) == 0 { // Delete empty files if len(contents) == 0 { // Delete empty files
err := os.Remove(filename) err := os.Remove(filename)
slog.Error("error deleting empty file", slog.Error("error deleting empty file",
@ -63,8 +63,9 @@ func SaveFile(filename string, contents []byte) error {
} }
// ListFiles returns slice of filenames in a directory without extensions or path // ListFiles 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 ListFiles(directory string) ([]string, error) {
filenames, err := filepath.Glob("data/" + directory + "/*.txt") filenames, err := filepath.Glob("data/" + path.Clean(directory) + "/*.txt")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -93,15 +94,16 @@ func TodayDate() string {
if GraceActive() { if GraceActive() {
dateFormatted = time.Now().In(Cfg.Timezone).AddDate(0, 0, -1).Format(time.DateOnly) 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 return dateFormatted
} }
// ReadToday runs ReadFile with today's date as filename // ReadToday runs ReadFile with today's date as filename
func ReadToday() ([]byte, error) { func ReadToday() ([]byte, error) {
return ReadFile("day/" + TodayDate()) return ReadFile(DataFile("day/" + TodayDate()))
} }
// SaveToday runs SaveFile with today's date as filename // SaveToday runs SaveFile with today's date as filename
func SaveToday(contents []byte) error { func SaveToday(contents []byte) error {
return SaveFile("day/"+TodayDate(), contents) return SaveFile(DataFile("day/"+TodayDate()), contents)
} }

View file

@ -23,5 +23,5 @@
"info.version.link": "source and changelog", "info.version.link": "source and changelog",
"info.export": "Export data", "info.export": "Export data",
"info.readme": "Edit readme.txt", "info.readme": "Edit readme.txt",
"info.reload": "Reload config" "info.config": "Edit config"
} }

View file

@ -23,5 +23,5 @@
"info.version.link": "исходный код", "info.version.link": "исходный код",
"info.export": "Экспорт данных", "info.export": "Экспорт данных",
"info.readme": "Редактировать readme.txt", "info.readme": "Редактировать readme.txt",
"info.reload": "Перезагрузить конфиг" "info.config": "Редактировать конфиг"
} }

View file

@ -15,7 +15,7 @@ type AppInfo struct {
// Info contains app information // Info contains app information
var Info = AppInfo{ var Info = AppInfo{
Version: "0.5.0", Version: "0.6.0",
SourceLink: "https://git.a71.su/Andrew71/hibiscus", SourceLink: "https://git.a71.su/Andrew71/hibiscus",
} }

View file

@ -25,7 +25,8 @@
</main> </main>
{{template "footer" .}} {{template "footer" .}}
<script defer> <script defer>
const langName="{{ translatableText "lang" }}"; const langName="{{ config.Language }}";
const timeZone="{{ config.Timezone }}";
beginTimeUpdater() beginTimeUpdater()
</script> </script>
</body> </body>

View file

@ -2,8 +2,8 @@
<h2>{{ translatableText "title.info" }}</h2> <h2>{{ translatableText "title.info" }}</h2>
<ul> <ul>
<li>{{ translatableText "info.version" }} - {{ info.Version }} (<a href="{{ .SourceLink }}">{{ translatableText "info.version.link" }}</a>)</li> <li>{{ translatableText "info.version" }} - {{ info.Version }} (<a href="{{ .SourceLink }}">{{ translatableText "info.version.link" }}</a>)</li>
<li><a href="/api/export" download="hibiscus">{{ translatableText "info.export" }}</a></li> <li><a href="/config">{{ translatableText "info.config" }}</a></li>
<li><a href="/readme">{{ translatableText "info.readme" }}</a></li> <li><a href="/readme">{{ translatableText "info.readme" }}</a></li>
<li><a href="/api/reload">{{ translatableText "info.reload" }}</a></li> <li><a href="/api/export" download="hibiscus">{{ translatableText "info.export" }}</a></li>
</ul> </ul>
{{end}} {{end}}

View file

@ -3,7 +3,8 @@ function formatDate(date) {
let dateFormat = new Intl.DateTimeFormat([langName, "en"], { let dateFormat = new Intl.DateTimeFormat([langName, "en"], {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric',
timeZone: timeZone
}) })
return dateFormat.format(date) return dateFormat.format(date)
} }

View file

@ -113,7 +113,7 @@ func GetDays(w http.ResponseWriter, r *http.Request) {
if v == TodayDate() { if v == TodayDate() {
dayString = TranslatableText("link.today") dayString = TranslatableText("link.today")
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:]) dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
} else if GraceActive() && v > TodayDate() { } else if v > TodayDate() {
dayString = TranslatableText("link.tomorrow") dayString = TranslatableText("link.tomorrow")
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:]) dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
} }
@ -189,7 +189,7 @@ func GetDay(w http.ResponseWriter, r *http.Request) {
title = t.Format("02 Jan 2006") title = t.Format("02 Jan 2006")
} }
GetEntry(w, r, title, "day/"+dayString, false) GetEntry(w, r, title, DataFile("day/"+dayString), false)
} }
// GetNote renders HTML page for a note // GetNote renders HTML page for a note
@ -205,7 +205,7 @@ func GetNote(w http.ResponseWriter, r *http.Request) {
noteString = decodedNote noteString = decodedNote
} }
GetEntry(w, r, noteString, "notes/"+noteString, true) GetEntry(w, r, noteString, DataFile("notes/"+noteString), true)
} }
// PostNote saves a note form and redirects back to GET // PostNote saves a note form and redirects back to GET
@ -216,7 +216,7 @@ func PostNote(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified"))) HandleWrite(w.Write([]byte("note not specified")))
return return
} }
err := SaveFile("notes/"+noteString, []byte(r.FormValue("text"))) err := SaveFile(DataFile("notes/"+noteString), []byte(r.FormValue("text")))
if err != nil { if err != nil {
slog.Error("error saving a note", "note", noteString, "error", err) slog.Error("error saving a note", "note", noteString, "error", err)
} }
@ -225,14 +225,32 @@ func PostNote(w http.ResponseWriter, r *http.Request) {
// GetReadme calls GetEntry for readme.txt // GetReadme calls GetEntry for readme.txt
func GetReadme(w http.ResponseWriter, r *http.Request) { func GetReadme(w http.ResponseWriter, r *http.Request) {
GetEntry(w, r, "readme.txt", "readme", true) GetEntry(w, r, "readme.txt", DataFile("readme"), true)
} }
// PostReadme saves contents of readme.txt file // PostReadme saves contents of readme.txt file
func PostReadme(w http.ResponseWriter, r *http.Request) { func PostReadme(w http.ResponseWriter, r *http.Request) {
err := SaveFile("readme", []byte(r.FormValue("text"))) err := SaveFile(DataFile("readme"), []byte(r.FormValue("text")))
if err != nil { if err != nil {
slog.Error("error saving readme", "error", err) slog.Error("error saving readme", "error", err)
} }
http.Redirect(w, r, r.Header.Get("Referer"), 302) http.Redirect(w, r, r.Header.Get("Referer"), 302)
} }
// GetConfig calls GetEntry for Cfg
func GetConfig(w http.ResponseWriter, r *http.Request) {
GetEntry(w, r, "config.txt", ConfigFile, true)
}
// PostConfig saves new Cfg
func PostConfig(w http.ResponseWriter, r *http.Request) {
err := SaveFile(ConfigFile, []byte(r.FormValue("text")))
if err != nil {
slog.Error("error saving config", "error", err)
}
err = Cfg.Reload()
if err != nil {
slog.Error("error reloading config", "error", err)
}
http.Redirect(w, r, r.Header.Get("Referer"), 302)
}

View file

@ -28,6 +28,8 @@ func Serve() {
userRouter.Get("/info", GetInfo) userRouter.Get("/info", GetInfo)
userRouter.Get("/readme", GetReadme) userRouter.Get("/readme", GetReadme)
userRouter.Post("/readme", PostReadme) userRouter.Post("/readme", PostReadme)
userRouter.Get("/config", GetConfig)
userRouter.Post("/config", PostConfig)
r.Mount("/", userRouter) r.Mount("/", userRouter)
// API ============= // API =============