Compare commits

..

No commits in common. "master" and "v1.1.2" have entirely different histories.

23 changed files with 235 additions and 187 deletions

View file

@ -1,29 +1,17 @@
# Changelog # Changelog
This file keeps track of changes in a human-readable fashion This file keeps track of changes in more human-readable fashion
## v1.1.4
* Fixed HTML `lang` tag
* Theme CSS link is now only present if non-default is set
* Improved template consistency (backend)
## v1.1.3
This release mostly consists of backend improvements
* List items no longer replace hyphens with spaces for consistency
* Telegram message for SCRAM is now translatable
* Ensured HTML escape in list descriptions
* Refactored many methods, improved comments
## v1.1.2 ## v1.1.2
This release contains a few bug fixes This version contains changes from pull request #2 by Rithas K.
* Real IPs are now logged (By Rithas K.) * Real IPs are now logged
* CSS now has `box-sizing: border-box` to fix textarea in some cases (By Rithas K.) * Textarea has been fixed Safari
* Done some minor code housekeeping * Done some minor behind-the-scenes housekeeping
## v1.1.1 ## v1.1.1
This release is mostly a technicality, with a move over to GitHub (`ghcr.io/andrew-71/hibiscus`) for packages due to DockerHub's prior anti-Russian actions making old "CI/CD" unsustainable. This release is mostly a technicality, with a move over to GitHub (`ghcr.io/andrew-71/hibiscus`) for packages due to DockerHub's anti-Russian actions making old "CI/CD" impossible.
## v1.1.0 ## v1.1.0
* You can now specify the Telegram *topic* to send notification to via `tg_topic` config key (By Rithas K.) * You can now specify the Telegram *topic* to send notification to via `tg_topic` config key (By Rithas K.!)
* The Telegram message is now partially translated * The Telegram message is now partially translated
* Fixed CSS `margin` and `text-align` inherited from my website * Fixed CSS `margin` and `text-align` inherited from my website
## v1.0.0 ## v1.0.0
This release includes several **breaking** changes This release includes several **breaking** changes
* Made a new favicon * Made a new favicon

View file

@ -11,7 +11,7 @@ As a result, I can't guarantee that it's either secure or stable.
* You can easily export the files in a `.zip` archive for backups * You can easily export the files in a `.zip` archive for backups
* Everything is plain(text) and simple. No databases, encryption, OAuth, or anything fancy. Even the password is plainte- *wait is this a feature?* * Everything is plain(text) and simple. No databases, encryption, OAuth, or anything fancy. Even the password is plainte- *wait is this a feature?*
* [Docker support](#docker-deployment) (in fact, that's probably the best way to run this) * Docker support (in fact, that's probably the best way to run this)
* Optional Telegram notifications for failed login attempts * Optional Telegram notifications for failed login attempts
## Technical details ## Technical details
@ -19,7 +19,7 @@ As a result, I can't guarantee that it's either secure or stable.
You can read a relevant entry in my blog [here](https://a71.su/notes/hibiscus/). You can read a relevant entry in my blog [here](https://a71.su/notes/hibiscus/).
It provides some useful information and context for why this app exists in the first place. It provides some useful information and context for why this app exists in the first place.
This repository is [self-hosted by me](https://git.a71.su/Andrew71/hibiscus), but [mirrored to GitHub](https://github.com/Andrew-71/hibiscus) in case my server goes down. This repository is [mirrored to GitHub](https://github.com/Andrew-71/hibiscus) in case my server goes down.
### Data format: ### Data format:
``` ```
@ -39,19 +39,19 @@ config
Deleting notes is done by clearing contents and clicking "Save" - the app deletes empty files when saving. Deleting notes is done by clearing contents and clicking "Save" - the app deletes empty files when saving.
### Config options: ### Config options:
Below are the available configuration options and their defaults. Below are available configuration options and their defaults.
The settings are defined as newline separated `key=value` pairs in the config file. The settings are defined as newline separated key=value pairs in config.txt.
If you do not provide an option, the default will be used. If you do not provide an option in your config, it will be using the default.
Please don't include the bash-style "comments" in your actual config, Please don't include the bash-style "comments" in your actual config,
they are provided purely for demonstration and **will break the config if present**. they are provided purely for demonstration only and **will break the config if present**.
``` ```
username=admin # Your username username=admin # Your username
password=admin # Your password password=admin # Your password
port=7101 # What port to run on (probably leave on 7101 if using docker) port=7101 # What port to run on (probably leave on 7101 if using docker)
timezone=Local # IANA Time zone database identifier ("UTC", Local", "Europe/Moscow" etc.), Defaults to Local if can't parse. timezone=Local # IANA Time zone database identifier ("UTC", Local", "Europe/Moscow" etc.), Defaults to Local if can't parse.
grace_period=0s # Time after a new day begins, but before the switch to next day's file. e.g. 3h26m - files will change at 3:26am 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 (available - en, ru) language=en # ISO-639 language code (available - en, ru)
theme="" # Picked theme (available - default (if left empty), high-contrast, lavender, gruvbox, sans) theme=default # Picked theme (available - default, high-contrast, lavender, gruvbox, sans)
title=🌺 Hibiscus.txt # The text in the header 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
@ -68,10 +68,10 @@ tg_topic=message_thread_id
The Docker images are hosted via GitHub over at `ghcr.io/andrew-71/hibiscus:<tag>`, The Docker images are hosted via GitHub over at `ghcr.io/andrew-71/hibiscus:<tag>`,
built from the [Dockerfile](./Dockerfile). built from the [Dockerfile](./Dockerfile).
This repo contains the [compose.yml](./compose.yml) that I personally use. This repo contains the [compose.yml](./compose.yml) that I personally use.
*Note: an extremely outdated self-hosted [package](https://git.a71.su/Andrew71/hibiscus/packages) will be provided for some time.* *Note: an outdated personally hosted [package](https://git.a71.su/Andrew71/hibiscus/packages) will be provided for some time.*
### Executable flags ### Executable flags
If you decide to use plain executable instead of docker, it supports the following flags: If you for some reason decide to run plain executable instead of docker, it supports following flags:
``` ```
-config string -config string
override config file location override config file location
@ -86,7 +86,7 @@ If you decide to use plain executable instead of docker, it supports the followi
``` ```
### API methods ### API methods
You can access the API at `/api/<method>`. It is protected by same HTTP Basic Auth as "normal" routes. You can access the API at `/api/<method>`. They are protected by same HTTP Basic Auth as "normal" site.
``` ```
GET /today - get file contents for today GET /today - get file contents for today
POST /today - save request body into today's file POST /today - save request body into today's file

View file

@ -1,7 +1,6 @@
# TODO # TODO
List of things to add to this project List of things to add to this project
* Auth improvement so it DOESN'T ASK ME FOR PASSWORD EVERY DAY UGH XD
* Forward/backward buttons for days * Forward/backward buttons for days
## Brainstorming ## Brainstorming

56
api.go
View file

@ -10,15 +10,15 @@ import (
"os" "os"
) )
// HandleWrite handles error in output of ResponseWriter.Write. // HandleWrite checks for error in ResponseWriter.Write output
func HandleWrite(_ int, err error) { func HandleWrite(_ int, err error) {
if err != nil { if err != nil {
slog.Error("error writing response", "error", err) slog.Error("error writing response", "error", err)
} }
} }
// GetFileApi returns raw contents of a file. // GetFile returns raw contents of a file
func GetFileApi(filename string, w http.ResponseWriter) { func GetFile(filename string, w http.ResponseWriter) {
fileContents, err := ReadFile(filename) fileContents, err := ReadFile(filename)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@ -31,8 +31,8 @@ func GetFileApi(filename string, w http.ResponseWriter) {
HandleWrite(w.Write(fileContents)) HandleWrite(w.Write(fileContents))
} }
// PostFileApi writes contents of Request.Body to a file. // PostFile writes request's body contents to a file
func PostFileApi(filename string, w http.ResponseWriter, r *http.Request) { func PostFile(filename string, w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -49,7 +49,7 @@ func PostFileApi(filename string, w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// GetFileList returns JSON list of filenames in a directory without extensions or path. // GetFileList returns JSON list of filenames in a directory without extensions or path
func GetFileList(directory string, w http.ResponseWriter) { func GetFileList(directory string, w http.ResponseWriter) {
filenames, err := ListFiles(directory) filenames, err := ListFiles(directory)
if err != nil { if err != nil {
@ -64,7 +64,7 @@ func GetFileList(directory string, w http.ResponseWriter) {
HandleWrite(w.Write(filenamesJson)) HandleWrite(w.Write(filenamesJson))
} }
// GetDayApi returns raw contents of a daily file specified in URL. // GetDayApi returns a contents of a daily file specified in URL
func GetDayApi(w http.ResponseWriter, r *http.Request) { func GetDayApi(w http.ResponseWriter, r *http.Request) {
dayString := chi.URLParam(r, "day") dayString := chi.URLParam(r, "day")
if dayString == "" { if dayString == "" {
@ -72,10 +72,20 @@ func GetDayApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("day not specified"))) HandleWrite(w.Write([]byte("day not specified")))
return return
} }
GetFileApi(DataFile("day/"+dayString), w) GetFile(DataFile("day/"+dayString), w)
} }
// GetNoteApi returns contents of a note specified in URL. // GetTodayApi runs GetFile with today's date as filename
func GetTodayApi(w http.ResponseWriter, _ *http.Request) {
GetFile(DataFile("day/"+TodayDate()), w)
}
// PostTodayApi runs PostFile with today's date as filename
func PostTodayApi(w http.ResponseWriter, r *http.Request) {
PostFile(DataFile("day/"+TodayDate()), w, r)
}
// GetNoteApi returns contents of a note specified in URL
func GetNoteApi(w http.ResponseWriter, r *http.Request) { func GetNoteApi(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note") noteString := chi.URLParam(r, "note")
if noteString == "" { if noteString == "" {
@ -83,10 +93,10 @@ func GetNoteApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified"))) HandleWrite(w.Write([]byte("note not specified")))
return return
} }
GetFileApi(DataFile("notes/"+noteString), w) GetFile(DataFile("notes/"+noteString), w)
} }
// PostNoteApi writes contents of Request.Body to a note specified in URL. // PostNoteApi writes request's body contents to a note specified in URL
func PostNoteApi(w http.ResponseWriter, r *http.Request) { func PostNoteApi(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note") noteString := chi.URLParam(r, "note")
if noteString == "" { if noteString == "" {
@ -94,10 +104,10 @@ func PostNoteApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified"))) HandleWrite(w.Write([]byte("note not specified")))
return return
} }
PostFileApi(DataFile("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
func GraceActiveApi(w http.ResponseWriter, r *http.Request) { func GraceActiveApi(w http.ResponseWriter, r *http.Request) {
value := "false" value := "false"
if GraceActive() { if GraceActive() {
@ -106,3 +116,23 @@ func GraceActiveApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte(value))) HandleWrite(w.Write([]byte(value)))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// GetVersionApi returns current app version
func GetVersionApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte(Info.Version)))
w.WriteHeader(http.StatusOK)
}
// ConfigReloadApi reloads the config
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"), 302)
return
}
w.WriteHeader(http.StatusOK)
}

16
auth.go
View file

@ -19,10 +19,10 @@ type failedLogin struct {
var failedLogins []failedLogin var failedLogins []failedLogin
// NoteLoginFail attempts to log and counteract bruteforce attacks. // NoteLoginFail attempts to log and counteract bruteforce/spam attacks
func NoteLoginFail(username string, password string, r *http.Request) { func NoteLoginFail(username string, password string, r *http.Request) {
slog.Warn("failed auth", "username", username, "password", password, "address", r.RemoteAddr) 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(TranslatableText("info.telegram_notification")+":\nusername=%s\npassword=%s\nremote=%s", username, password, r.RemoteAddr))
attempt := failedLogin{username, password, time.Now()} attempt := failedLogin{username, password, time.Now()}
updatedLogins := []failedLogin{attempt} updatedLogins := []failedLogin{attempt}
@ -40,8 +40,8 @@ func NoteLoginFail(username string, password string, r *http.Request) {
} }
// BasicAuth is a middleware that handles authentication & authorization for the app. // 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. // 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). // 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 { func BasicAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth() username, password, ok := r.BasicAuth()
@ -69,14 +69,14 @@ func BasicAuth(next http.Handler) http.Handler {
}) })
} }
// Scram shuts down the service, useful in case of suspected attack. // Scram shuts down the service, useful in case of suspected attack
func Scram() { func Scram() {
slog.Warn("SCRAM triggered, shutting down") slog.Warn("SCRAM triggered, shutting down")
NotifyTelegram(TranslatableText("info.telegram.scram")) NotifyTelegram("Hibiscus SCRAM triggered, shutting down")
os.Exit(0) os.Exit(0) // TODO: should this be 0 or 1?
} }
// NotifyTelegram attempts to send a message to admin through Telegram. // NotifyTelegram attempts to send a message to admin through Telegram
func NotifyTelegram(msg string) { func NotifyTelegram(msg string) {
if Cfg.TelegramChat == "" || Cfg.TelegramToken == "" { if Cfg.TelegramChat == "" || Cfg.TelegramToken == "" {
slog.Debug("ignoring telegram request due to lack of credentials") slog.Debug("ignoring telegram request due to lack of credentials")

View file

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"net/http"
"os" "os"
"reflect" "reflect"
"strconv" "strconv"
@ -41,7 +40,7 @@ var DefaultConfig = Config{
Timezone: time.Local, Timezone: time.Local,
GraceTime: 0, GraceTime: 0,
Language: "en", Language: "en",
Theme: "", Theme: "default",
Title: "🌺 Hibiscus.txt", Title: "🌺 Hibiscus.txt",
LogToFile: false, LogToFile: false,
LogFile: "config/log.txt", LogFile: "config/log.txt",
@ -52,7 +51,7 @@ var DefaultConfig = Config{
TelegramTopic: "", TelegramTopic: "",
} }
// String returns string representation of modified and mandatory config options. // String returns text version of modified and mandatory config options
func (c *Config) String() string { func (c *Config) String() string {
output := "" output := ""
v := reflect.ValueOf(*c) v := reflect.ValueOf(*c)
@ -69,13 +68,11 @@ func (c *Config) String() string {
return output return output
} }
// Reload resets, then loads config from the ConfigFile.
// It creates the file with mandatory options if it is missing.
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
} }
@ -152,40 +149,17 @@ func (c *Config) Reload() error {
return SetLanguage(c.Language) // Load selected language return SetLanguage(c.Language) // Load selected language
} }
// Read gets raw contents from ConfigFile. // Read gets raw contents from ConfigFile
func (c *Config) Read() ([]byte, error) { func (c *Config) Read() ([]byte, error) {
return ReadFile(ConfigFile) return ReadFile(ConfigFile)
} }
// Save writes config's contents to the ConfigFile. // Save writes to ConfigFile
func (c *Config) Save() error { func (c *Config) Save(contents []byte) error {
return SaveFile(ConfigFile, []byte(c.String())) return SaveFile(ConfigFile, contents)
} }
// PostConfig calls PostEntry for config file, then reloads the config. // ConfigInit loads config on startup
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"), 302)
return
}
w.WriteHeader(http.StatusOK)
}
// ConfigInit loads config on startup.
func ConfigInit() Config { func ConfigInit() Config {
cfg := Config{} cfg := Config{}
err := cfg.Reload() err := cfg.Reload()

View file

@ -2,4 +2,4 @@ username=admin
password=admin password=admin
port=7101 port=7101
timezone=Local timezone=Local
language=en language=en

View file

@ -11,7 +11,7 @@ import (
var ExportPath = "data/export.zip" var ExportPath = "data/export.zip"
// Export saves a .zip archive of the data folder to a file. // Export saves a .zip archive of the data folder to the passed filename
func Export(filename string) error { func Export(filename string) error {
file, err := os.Create(filename) file, err := os.Create(filename)
if err != nil { if err != nil {
@ -61,8 +61,7 @@ func Export(filename string) error {
return file.Close() return file.Close()
} }
// GetExport returns a .zip archive with contents of the data folder. // GetExport returns a .zip archive with contents of the data folder
// As a side effect, it creates the file in there.
func GetExport(w http.ResponseWriter, r *http.Request) { func GetExport(w http.ResponseWriter, r *http.Request) {
err := Export(ExportPath) err := Export(ExportPath)
if err != nil { if err != nil {

View file

@ -11,30 +11,34 @@ import (
"time" "time"
) )
// DataFile modifies file path to ensure it's a .txt inside the data folder. // DataFile modifies file path to ensure it's a .txt inside the data folder
func DataFile(filename string) string { func DataFile(filename string) string {
return "data/" + path.Clean(filename) + ".txt" return "data/" + path.Clean(filename) + ".txt"
} }
// ReadFile returns contents of a file. // ReadFile returns raw contents of a file
func ReadFile(filename string) ([]byte, error) { func ReadFile(filename string) ([]byte, error) {
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", "error", err, "file", filename) slog.Error("error reading file",
"error", err,
"file", filename)
return nil, err return nil, err
} }
return fileContents, nil return fileContents, nil
} }
// SaveFile Writes 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)
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", "error", err, "file", filename) slog.Error("error deleting empty file",
"error", err,
"file", filename)
return err return err
} }
err := os.MkdirAll(path.Dir(filename), 0755) // Create dir in case it doesn't exist yet to avoid errors err := os.MkdirAll(path.Dir(filename), 0755) // Create dir in case it doesn't exist yet to avoid errors
@ -44,17 +48,21 @@ func SaveFile(filename string, contents []byte) error {
} }
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil { if err != nil {
slog.Error("error opening/creating file", "error", err, "file", filename) slog.Error("error opening/making file",
"error", err,
"file", filename)
return err return err
} }
if _, err := f.Write(contents); err != nil { if _, err := f.Write(contents); err != nil {
slog.Error("error writing to file", "error", err, "file", filename) slog.Error("error writing to file",
"error", err,
"file", filename)
return err return err
} }
return nil return nil
} }
// 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? // 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/" + path.Clean(directory) + "/*.txt") filenames, err := filepath.Glob("data/" + path.Clean(directory) + "/*.txt")
@ -68,7 +76,7 @@ func ListFiles(directory string) ([]string, error) {
return filenames, nil return filenames, nil
} }
// GraceActive returns whether the grace period (Cfg.GraceTime) is active. Grace period has minute precision // GraceActive returns whether the grace period (Cfg.GraceTime) is active
func GraceActive() bool { func GraceActive() bool {
t := time.Now().In(Cfg.Timezone) t := time.Now().In(Cfg.Timezone)
active := (60*t.Hour() + t.Minute()) < int(Cfg.GraceTime.Minutes()) active := (60*t.Hour() + t.Minute()) < int(Cfg.GraceTime.Minutes())
@ -80,7 +88,7 @@ func GraceActive() bool {
return active return active
} }
// TodayDate returns today's formatted date. It accounts for Config.GraceTime. // TodayDate returns today's formatted date. It accounts for Config.GraceTime
func TodayDate() string { func TodayDate() string {
dateFormatted := time.Now().In(Cfg.Timezone).Format(time.DateOnly) dateFormatted := time.Now().In(Cfg.Timezone).Format(time.DateOnly)
if GraceActive() { if GraceActive() {
@ -89,3 +97,13 @@ func TodayDate() string {
slog.Debug("today", "time", time.Now().In(Cfg.Timezone).Format(time.DateTime)) 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
func ReadToday() ([]byte, error) {
return ReadFile(DataFile("day/" + TodayDate()))
}
// SaveToday runs SaveFile with today's date as filename
func SaveToday(contents []byte) error {
return SaveFile(DataFile("day/"+TodayDate()), contents)
}

View file

@ -5,7 +5,7 @@ import (
"log" "log"
) )
// FlagInit processes app flags. // FlagInit processes app flags
func FlagInit() { func FlagInit() {
config := flag.String("config", "", "override config file") config := flag.String("config", "", "override config file")
username := flag.String("user", "", "override username") username := flag.String("user", "", "override username")

View file

@ -10,7 +10,7 @@ import (
var I18n embed.FS var I18n embed.FS
var Translations = map[string]string{} var Translations = map[string]string{}
// SetLanguage loads a json file for selected language into the Translations map, with English language as a fallback. // SetLanguage loads a json file for selected language into the Translations map, with english language as a fallback
func SetLanguage(language string) error { func SetLanguage(language string) error {
loadLanguage := func(language string) error { loadLanguage := func(language string) error {
filename := "i18n/" + language + ".json" filename := "i18n/" + language + ".json"
@ -23,15 +23,14 @@ func SetLanguage(language string) error {
} }
return json.Unmarshal(fileContents, &Translations) return json.Unmarshal(fileContents, &Translations)
} }
Translations = map[string]string{} // Clear the map to avoid previous language remaining err := loadLanguage("en") // Load english as fallback language
err := loadLanguage("en") // Load English as fallback
if err != nil { if err != nil {
return err return err
} }
return loadLanguage(language) return loadLanguage(language)
} }
// TranslatableText attempts to match an id to a string in current language. // TranslatableText attempts to match an id to a string in current language
func TranslatableText(id string) string { func TranslatableText(id string) string {
if v, ok := Translations[id]; !ok { if v, ok := Translations[id]; !ok {
return id return id

View file

@ -1,6 +1,4 @@
{ {
"lang": "en-UK",
"title.today": "Your day so far", "title.today": "Your day so far",
"title.days": "Previous days", "title.days": "Previous days",
"title.notes": "Notes", "title.notes": "Notes",
@ -26,6 +24,5 @@
"info.readme": "Edit readme.txt", "info.readme": "Edit readme.txt",
"info.config": "Edit config", "info.config": "Edit config",
"info.telegram.auth_fail": "Failed auth attempt in Hibiscus.txt", "info.telegram_notification": "Failed auth attempt in Hibiscus.txt"
"info.telegram.scram": "Hibiscus SCRAM triggered, shutting down"
} }

View file

@ -1,6 +1,4 @@
{ {
"lang": "ru",
"title.today": "Сегодняшний день", "title.today": "Сегодняшний день",
"title.days": "Предыдущие дни", "title.days": "Предыдущие дни",
"title.notes": "Заметки", "title.notes": "Заметки",
@ -13,7 +11,7 @@
"link.info": "системная информация", "link.info": "системная информация",
"time.date": "Сегодня", "time.date": "Сегодня",
"time.grace": "редактируется вчерашний день", "time.grace": "льготный период",
"button.save": "Сохранить", "button.save": "Сохранить",
"button.notes": "Новая заметка", "button.notes": "Новая заметка",
"prompt.notes": "Название заметки", "prompt.notes": "Название заметки",
@ -26,6 +24,5 @@
"info.readme": "Редактировать readme.txt", "info.readme": "Редактировать readme.txt",
"info.config": "Редактировать конфиг", "info.config": "Редактировать конфиг",
"info.telegram_notification": "Неверная попытка авторизации в Hibiscus.txt", "info.telegram_notification": "Неверная попытка авторизации в Hibiscus.txt"
"info.telegram.scram": "Активирована функция SCRAM в Hibiscus.txt, сервер выключается"
} }

12
info.go
View file

@ -13,13 +13,13 @@ type AppInfo struct {
SourceLink string SourceLink string
} }
// Info contains app information. // Info contains app information
var Info = AppInfo{ var Info = AppInfo{
Version: "1.1.4", Version: "1.1.2",
SourceLink: "https://git.a71.su/Andrew71/hibiscus", SourceLink: "https://git.a71.su/Andrew71/hibiscus",
} }
// GetInfo renders the info page. // GetInfo renders the info page
func GetInfo(w http.ResponseWriter, r *http.Request) { func GetInfo(w http.ResponseWriter, r *http.Request) {
err := infoTemplate.ExecuteTemplate(w, "base", Info) err := infoTemplate.ExecuteTemplate(w, "base", Info)
if err != nil { if err != nil {
@ -28,9 +28,3 @@ func GetInfo(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
// GetVersionApi returns current app version.
func GetVersionApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte(Info.Version)))
w.WriteHeader(http.StatusOK)
}

View file

@ -10,7 +10,7 @@ import (
var DebugMode = false var DebugMode = false
// LogInit makes slog output to both os.Stdout and a file if needed, and sets slog.LevelDebug if enabled. // LogInit makes slog output to both stdout and a file if needed, and enables debug mode if selected
func LogInit() { func LogInit() {
var w io.Writer var w io.Writer
if Cfg.LogToFile { if Cfg.LogToFile {

View file

@ -1,11 +1,11 @@
{{ define "header" }} {{define "header"}}
<header> <header>
<h1>{{ config.Title }}</h1> <h1>{{ config.Title }}</h1>
<p>{{ translatableText "time.date" }} <span id="today-date">a place</span> <span id="grace" hidden>({{ translatableText "time.grace" }})</span></p> <p>{{translatableText "time.date"}} <span id="today-date">a place</span> <span id="grace" hidden>({{ translatableText "time.grace" }})</span></p>
</header> </header>
{{ end }} {{end}}
{{- define "base" -}} {{define "base"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ translatableText "lang" }}"> <html lang="{{ translatableText "lang" }}">
<head> <head>
@ -14,16 +14,16 @@
<link rel="manifest" href="/public/manifest.json" /> <link rel="manifest" href="/public/manifest.json" />
<link rel="icon" type="image/x-icon" href="/public/favicon.ico"> <link rel="icon" type="image/x-icon" href="/public/favicon.ico">
<link rel="stylesheet" href="/public/main.css"> <link rel="stylesheet" href="/public/main.css">
{{- if config.Theme -}}<link rel="stylesheet" href="/public/themes/{{ config.Theme }}.css">{{ end }} <link rel="stylesheet" href="/public/themes/{{ config.Theme }}.css">
<script src="/public/main.js"></script> <script src="/public/main.js"></script>
<title>Hibiscus.txt</title> <title>Hibiscus.txt</title>
</head> </head>
<body> <body>
{{- template "header" . -}} {{template "header" .}}
<main> <main>
{{- template "main" . -}} {{template "main" .}}
</main> </main>
{{- template "footer" . -}} {{template "footer" .}}
<script defer> <script defer>
const langName="{{ config.Language }}"; const langName="{{ config.Language }}";
const timeZone="{{ config.Timezone }}"; const timeZone="{{ config.Timezone }}";
@ -31,11 +31,11 @@
</script> </script>
</body> </body>
</html> </html>
{{ end }} {{end}}
{{ define "footer" }} {{define "footer"}}
<footer id="footer"> <footer id="footer">
<p><a href="/">{{ translatableText "link.today" }}</a> | <a href="/day">{{ translatableText "link.days" }}</a> | <a href="/notes">{{ translatableText "link.notes" }}</a> <p><a href="/">{{ translatableText "link.today" }}</a> | <a href="/day">{{ translatableText "link.days" }}</a> | <a href="/notes">{{ translatableText "link.notes" }}</a>
<span style="float:right;"><a class="no-accent" href="/info" title="{{ translatableText "link.info" }}">v{{ info.Version }}</a></span></p> <span style="float:right;"><a class="no-accent" href="/info" title="{{ translatableText "link.info" }}">v{{ info.Version }}</a></span></p>
</footer> </footer>
{{ end }} {{end}}

View file

@ -1,7 +1,7 @@
{{ define "main" }} {{define "main"}}
<form method="POST"> <form method="POST">
<h2><label for="text">{{ .Title }}:</label></h2> <h2><label for="text">{{ .Title }}:</label></h2>
<textarea id="text" cols="40" rows="15" name="text">{{ .Content }}</textarea> <textarea id="text" cols="40" rows="15" name="text">{{ .Content }}</textarea>
<button type="submit">{{ translatableText "button.save" }}</button> <button type="submit">{{ translatableText "button.save" }}</button>
</form> </form>
{{ end }} {{end}}

View file

@ -1,4 +1,4 @@
{{ define "main" }} {{define "main"}}
<h2><label for="text">{{ .Title }}</label></h2> <h2><label for="text">{{ .Title }}</label></h2>
<textarea id="text" cols="40" rows="15" readonly>{{ .Content }}</textarea> <textarea id="text" cols="40" rows="15" readonly>{{ .Content }}</textarea>
{{ end }} {{end}}

View file

@ -1,4 +1,4 @@
{{ define "main" }} {{define "main"}}
<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>
@ -6,4 +6,4 @@
<li><a href="/readme">{{ translatableText "info.readme" }}</a></li> <li><a href="/readme">{{ translatableText "info.readme" }}</a></li>
<li><a href="/api/export" download="hibiscus">{{ translatableText "info.export" }}</a></li> <li><a href="/api/export" download="hibiscus">{{ translatableText "info.export" }}</a></li>
</ul> </ul>
{{ end }} {{end}}

View file

@ -1,9 +1,9 @@
{{ define "main" }} {{define "main"}}
<h2 class="list-title">{{ .Title }}</h2> <h2 class="list-title">{{.Title}}</h2>
<p class="list-desc">{{ .Description }}</p> <p class="list-desc">{{.Description}}</p>
<ul> <ul>
{{ range .Entries }} {{range .Entries}}
<li><a href="/{{.Link}}">{{.Title}}</a></li> <li><a href="/{{.Link}}">{{.Title}}</a></li>
{{ end }} {{end}}
</ul> </ul>
{{end}} {{end}}

View file

@ -0,0 +1 @@
/* Default theme is defined in main.css */

112
routes.go
View file

@ -27,17 +27,17 @@ type Entry struct {
type formatEntries func([]string) []Entry type formatEntries func([]string) []Entry
// Public contains the static files e.g. CSS, JS. // Public contains the static files e.g. CSS, JS
// //
//go:embed public //go:embed public
var Public embed.FS var Public embed.FS
// Pages contains the HTML templates used by the app. // Pages contains the HTML templates used by the app
// //
//go:embed pages //go:embed pages
var Pages embed.FS var Pages embed.FS
// EmbeddedPage returns contents of a file in Pages while "handling" potential errors. // EmbeddedPage returns contents of a file in Pages while "handling" potential errors
func EmbeddedPage(name string) []byte { func EmbeddedPage(name string) []byte {
data, err := Pages.ReadFile(name) data, err := Pages.ReadFile(name)
if err != nil { if err != nil {
@ -55,19 +55,49 @@ var editTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(P
var viewTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/entry.html")) var viewTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/entry.html"))
var listTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/list.html")) var listTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/list.html"))
// NotFound returns a user-friendly 404 error page. // NotFound returns a user-friendly 404 error page
func NotFound(w http.ResponseWriter, r *http.Request) { func NotFound(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404) w.WriteHeader(404)
HandleWrite(w.Write(EmbeddedPage("pages/error/404.html"))) HandleWrite(w.Write(EmbeddedPage("pages/error/404.html")))
} }
// InternalError returns a user-friendly 500 error page. // InternalError returns a user-friendly 500 error page
func InternalError(w http.ResponseWriter, r *http.Request) { func InternalError(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500) w.WriteHeader(500)
HandleWrite(w.Write(EmbeddedPage("pages/error/500.html"))) HandleWrite(w.Write(EmbeddedPage("pages/error/500.html")))
} }
// GetEntries handles showing a list. // GetToday renders HTML page for today's entry
func GetToday(w http.ResponseWriter, r *http.Request) {
day, err := ReadToday()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
day = []byte("")
} else {
slog.Error("error reading today's file", "error", err)
InternalError(w, r)
return
}
}
err = editTemplate.ExecuteTemplate(w, "base", Entry{Title: TranslatableText("title.today"), Content: string(day)})
if err != nil {
slog.Error("error executing template", "error", err)
InternalError(w, r)
return
}
}
// PostToday saves today's entry from form and redirects back to GET
func PostToday(w http.ResponseWriter, r *http.Request) {
err := SaveToday([]byte(r.FormValue("text")))
if err != nil {
slog.Error("error saving today's file", "error", err)
}
http.Redirect(w, r, r.Header.Get("Referer"), 302)
}
// GetEntries is a generic HTML renderer for a list
func GetEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) { func GetEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) {
filesList, err := ListFiles(dir) filesList, err := ListFiles(dir)
if err != nil { if err != nil {
@ -85,10 +115,10 @@ func GetEntries(w http.ResponseWriter, r *http.Request, title string, descriptio
} }
} }
// GetDays calls GetEntries for previous days' entries. // GetDays renders HTML list of previous days' entries
func GetDays(w http.ResponseWriter, r *http.Request) { func GetDays(w http.ResponseWriter, r *http.Request) {
description := template.HTML( description := template.HTML(
"<a href=\"#footer\">" + template.HTMLEscapeString(TranslatableText("prompt.days")) + "</a>") "<a href=\"#footer\">" + TranslatableText("prompt.days") + "</a>")
GetEntries(w, r, TranslatableText("title.days"), description, "day", func(files []string) []Entry { GetEntries(w, r, TranslatableText("title.days"), description, "day", func(files []string) []Entry {
var filesFormatted []Entry var filesFormatted []Entry
for i := range files { for i := range files {
@ -115,23 +145,23 @@ func GetDays(w http.ResponseWriter, r *http.Request) {
}) })
} }
// GetNotes calls GetEntries for all notes. // GetNotes renders HTML list of all notes
func GetNotes(w http.ResponseWriter, r *http.Request) { func GetNotes(w http.ResponseWriter, r *http.Request) {
// This is suboptimal, but will do... // This is suboptimal, but will do...
description := template.HTML( description := template.HTML(
"<a href=\"#\" onclick='newNote(\"" + template.HTMLEscapeString(TranslatableText("prompt.notes")) + "\")'>" + template.HTMLEscapeString(TranslatableText("button.notes")) + "</a>" + "<a href=\"#\" onclick='newNote(\"" + TranslatableText("prompt.notes") + "\")'>" + TranslatableText("button.notes") + "</a>" +
" <noscript>(" + template.HTMLEscapeString(TranslatableText("noscript.notes")) + ")</noscript>") " <noscript>(" + template.HTMLEscapeString(TranslatableText("noscript.notes")) + ")</noscript>")
GetEntries(w, r, TranslatableText("title.notes"), description, "notes", func(files []string) []Entry { GetEntries(w, r, TranslatableText("title.notes"), description, "notes", func(files []string) []Entry {
var filesFormatted []Entry var filesFormatted []Entry
for _, v := range files { for _, v := range files {
// titleString := strings.Replace(v, "-", " ", -1) // This would be cool, but what if I need a hyphen? titleString := strings.Replace(v, "-", " ", -1) // FIXME: what if I need a hyphen?
filesFormatted = append(filesFormatted, Entry{Title: v, Link: "notes/" + v}) filesFormatted = append(filesFormatted, Entry{Title: titleString, Link: "notes/" + v})
} }
return filesFormatted return filesFormatted
}) })
} }
// GetEntry handles showing a single file, editable or otherwise. // GetEntry handles showing a single file, editable or otherwise
func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename string, editable bool) { func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename string, editable bool) {
entry, err := ReadFile(filename) entry, err := ReadFile(filename)
if err != nil { if err != nil {
@ -155,19 +185,7 @@ func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename str
} }
} }
// PostEntry saves value of "text" HTML form component to a file and redirects back to Referer if present. // GetDay renders HTML page for a specific day entry
func PostEntry(filename string, w http.ResponseWriter, r *http.Request) {
err := SaveFile(filename, []byte(r.FormValue("text")))
if err != nil {
slog.Error("error saving file", "error", err, "file", filename)
}
if r.Referer() != "" {
http.Redirect(w, r, r.Header.Get("Referer"), 302)
return
}
}
// GetDay calls GetEntry for a day entry.
func GetDay(w http.ResponseWriter, r *http.Request) { func GetDay(w http.ResponseWriter, r *http.Request) {
dayString := chi.URLParam(r, "day") dayString := chi.URLParam(r, "day")
if dayString == "" { if dayString == "" {
@ -189,7 +207,7 @@ func GetDay(w http.ResponseWriter, r *http.Request) {
GetEntry(w, r, title, DataFile("day/"+dayString), false) GetEntry(w, r, title, DataFile("day/"+dayString), false)
} }
// GetNote calls GetEntry for a note. // GetNote renders HTML page for a note
func GetNote(w http.ResponseWriter, r *http.Request) { func GetNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note") noteString := chi.URLParam(r, "note")
if noteString == "" { if noteString == "" {
@ -205,7 +223,7 @@ func GetNote(w http.ResponseWriter, r *http.Request) {
GetEntry(w, r, noteString, DataFile("notes/"+noteString), true) GetEntry(w, r, noteString, DataFile("notes/"+noteString), true)
} }
// PostNote calls PostEntry for a note. // PostNote saves a note form and redirects back to GET
func PostNote(w http.ResponseWriter, r *http.Request) { func PostNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note") noteString := chi.URLParam(r, "note")
if noteString == "" { if noteString == "" {
@ -213,5 +231,41 @@ func PostNote(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified"))) HandleWrite(w.Write([]byte("note not specified")))
return return
} }
PostEntry(DataFile("notes/"+noteString), w, r) err := SaveFile(DataFile("notes/"+noteString), []byte(r.FormValue("text")))
if err != nil {
slog.Error("error saving a note", "note", noteString, "error", err)
}
http.Redirect(w, r, r.Header.Get("Referer"), 302)
}
// GetReadme calls GetEntry for readme.txt
func GetReadme(w http.ResponseWriter, r *http.Request) {
GetEntry(w, r, "readme.txt", DataFile("readme"), true)
}
// PostReadme saves contents of readme.txt file
func PostReadme(w http.ResponseWriter, r *http.Request) {
err := SaveFile(DataFile("readme"), []byte(r.FormValue("text")))
if err != nil {
slog.Error("error saving readme", "error", err)
}
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

@ -9,7 +9,7 @@ import (
"strconv" "strconv"
) )
// Serve starts the app's web server. // Serve starts the app's web server
func Serve() { func Serve() {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
@ -19,34 +19,32 @@ func Serve() {
// Routes ========== // Routes ==========
userRouter := chi.NewRouter() userRouter := chi.NewRouter()
userRouter.Use(BasicAuth) userRouter.Use(BasicAuth)
userRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { userRouter.Get("/", GetToday)
GetEntry(w, r, TranslatableText("title.today"), DataFile("day/"+TodayDate()), true) userRouter.Post("/", PostToday)
})
userRouter.Post("/", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) })
userRouter.Get("/day", GetDays) userRouter.Get("/day", GetDays)
userRouter.Get("/day/{day}", GetDay) userRouter.Get("/day/{day}", GetDay)
userRouter.Get("/notes", GetNotes) userRouter.Get("/notes", GetNotes)
userRouter.Get("/notes/{note}", GetNote) userRouter.Get("/notes/{note}", GetNote)
userRouter.Post("/notes/{note}", PostNote) userRouter.Post("/notes/{note}", PostNote)
userRouter.Get("/info", GetInfo) userRouter.Get("/info", GetInfo)
userRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "readme.txt", DataFile("readme"), true) }) userRouter.Get("/readme", GetReadme)
userRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("readme"), w, r) }) userRouter.Post("/readme", PostReadme)
userRouter.Get("/config", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "config.txt", ConfigFile, true) }) userRouter.Get("/config", GetConfig)
userRouter.Post("/config", PostConfig) userRouter.Post("/config", PostConfig)
r.Mount("/", userRouter) r.Mount("/", userRouter)
// API ============= // API =============
apiRouter := chi.NewRouter() apiRouter := chi.NewRouter()
apiRouter.Use(BasicAuth) apiRouter.Use(BasicAuth)
apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFileApi("readme", w) }) apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFile("readme", w) })
apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFileApi("readme", w, r) }) apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFile("readme", w, r) })
apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { GetFileList("day", w) }) apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { GetFileList("day", w) })
apiRouter.Get("/day/{day}", GetDayApi) apiRouter.Get("/day/{day}", GetDayApi)
apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { GetFileList("notes", w) }) apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { GetFileList("notes", w) })
apiRouter.Get("/notes/{note}", GetNoteApi) apiRouter.Get("/notes/{note}", GetNoteApi)
apiRouter.Post("/notes/{note}", PostNoteApi) apiRouter.Post("/notes/{note}", PostNoteApi)
apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { GetFileApi(DataFile("day/"+TodayDate()), w) }) apiRouter.Get("/today", GetTodayApi)
apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) }) apiRouter.Post("/today", PostTodayApi)
apiRouter.Get("/export", GetExport) apiRouter.Get("/export", GetExport)
apiRouter.Get("/grace", GraceActiveApi) apiRouter.Get("/grace", GraceActiveApi)
apiRouter.Get("/version", GetVersionApi) apiRouter.Get("/version", GetVersionApi)