Add config editing to UI
This commit is contained in:
parent
20ca509a2b
commit
fa2ecacce5
15 changed files with 74 additions and 55 deletions
|
@ -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
|
||||||
|
|
|
@ -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
11
TODO.md
|
@ -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
10
api.go
|
@ -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
|
||||||
|
|
27
config.go
27
config.go
|
@ -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{}
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
20
files.go
20
files.go
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
|
@ -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": "Редактировать конфиг"
|
||||||
}
|
}
|
2
info.go
2
info.go
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
30
routes.go
30
routes.go
|
@ -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)
|
||||||
|
}
|
||||||
|
|
2
serve.go
2
serve.go
|
@ -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 =============
|
||||||
|
|
Loading…
Reference in a new issue