Refactor functions and improve comments
This commit is contained in:
parent
f33206c99d
commit
9cab989b78
16 changed files with 148 additions and 205 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
@ -1,17 +1,24 @@
|
|||
# Changelog
|
||||
This file keeps track of changes in more human-readable fashion
|
||||
This file keeps track of changes in a human-readable fashion
|
||||
|
||||
## 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
|
||||
This version contains changes from pull request #2 by Rithas K.
|
||||
* Real IPs are now logged
|
||||
* Textarea has been fixed Safari
|
||||
* Done some minor behind-the-scenes housekeeping
|
||||
This release contains a few bug fixes
|
||||
* Real IPs are now logged (By Rithas K.)
|
||||
* CSS now has `box-sizing: border-box` to fix textarea in some cases (By Rithas K.)
|
||||
* Done some minor code housekeeping
|
||||
## 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 anti-Russian actions making old "CI/CD" impossible.
|
||||
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.
|
||||
## 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
|
||||
* Fixed CSS `margin` and `text-align` inherited from my website
|
||||
|
||||
## v1.0.0
|
||||
This release includes several **breaking** changes
|
||||
* Made a new favicon
|
||||
|
|
12
README.md
12
README.md
|
@ -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
|
||||
|
||||
* 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 (in fact, that's probably the best way to run this)
|
||||
* [Docker support](#docker-deployment) (in fact, that's probably the best way to run this)
|
||||
* Optional Telegram notifications for failed login attempts
|
||||
|
||||
## 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/).
|
||||
It provides some useful information and context for why this app exists in the first place.
|
||||
This repository is [mirrored to GitHub](https://github.com/Andrew-71/hibiscus) in case my server goes down.
|
||||
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.
|
||||
|
||||
### Data format:
|
||||
```
|
||||
|
@ -49,7 +49,7 @@ username=admin # Your username
|
|||
password=admin # Your password
|
||||
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.
|
||||
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. 3h26m - files will change at 3:26am
|
||||
language=en # ISO-639 language code (available - en, ru)
|
||||
theme=default # Picked theme (available - default, high-contrast, lavender, gruvbox, sans)
|
||||
title=🌺 Hibiscus.txt # The text in the header
|
||||
|
@ -68,10 +68,10 @@ tg_topic=message_thread_id
|
|||
The Docker images are hosted via GitHub over at `ghcr.io/andrew-71/hibiscus:<tag>`,
|
||||
built from the [Dockerfile](./Dockerfile).
|
||||
This repo contains the [compose.yml](./compose.yml) that I personally use.
|
||||
*Note: an outdated personally hosted [package](https://git.a71.su/Andrew71/hibiscus/packages) will be provided for some time.*
|
||||
*Note: an extremely outdated self-hosted [package](https://git.a71.su/Andrew71/hibiscus/packages) will be provided for some time.*
|
||||
|
||||
### Executable flags
|
||||
If you for some reason decide to run plain executable instead of docker, it supports following flags:
|
||||
If you decide to use plain executable instead of docker, it supports the following flags:
|
||||
```
|
||||
-config string
|
||||
override config file location
|
||||
|
@ -86,7 +86,7 @@ If you for some reason decide to run plain executable instead of docker, it supp
|
|||
```
|
||||
|
||||
### API methods
|
||||
You can access the API at `/api/<method>`. They are protected by same HTTP Basic Auth as "normal" site.
|
||||
You can access the API at `/api/<method>`. It is protected by same HTTP Basic Auth as "normal" routes.
|
||||
```
|
||||
GET /today - get file contents for today
|
||||
POST /today - save request body into today's file
|
||||
|
|
56
api.go
56
api.go
|
@ -10,15 +10,15 @@ import (
|
|||
"os"
|
||||
)
|
||||
|
||||
// HandleWrite checks for error in ResponseWriter.Write output
|
||||
// HandleWrite handles error in output of ResponseWriter.Write.
|
||||
func HandleWrite(_ int, err error) {
|
||||
if err != nil {
|
||||
slog.Error("error writing response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetFile returns raw contents of a file
|
||||
func GetFile(filename string, w http.ResponseWriter) {
|
||||
// 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) {
|
||||
|
@ -31,8 +31,8 @@ func GetFile(filename string, w http.ResponseWriter) {
|
|||
HandleWrite(w.Write(fileContents))
|
||||
}
|
||||
|
||||
// PostFile writes request's body contents to a file
|
||||
func PostFile(filename string, w http.ResponseWriter, r *http.Request) {
|
||||
// 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)
|
||||
|
@ -49,7 +49,7 @@ func PostFile(filename string, w http.ResponseWriter, r *http.Request) {
|
|||
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) {
|
||||
filenames, err := ListFiles(directory)
|
||||
if err != nil {
|
||||
|
@ -64,7 +64,7 @@ func GetFileList(directory string, w http.ResponseWriter) {
|
|||
HandleWrite(w.Write(filenamesJson))
|
||||
}
|
||||
|
||||
// GetDayApi returns a contents of a daily file specified in URL
|
||||
// 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 == "" {
|
||||
|
@ -72,20 +72,10 @@ func GetDayApi(w http.ResponseWriter, r *http.Request) {
|
|||
HandleWrite(w.Write([]byte("day not specified")))
|
||||
return
|
||||
}
|
||||
GetFile(DataFile("day/"+dayString), w)
|
||||
GetFileApi(DataFile("day/"+dayString), w)
|
||||
}
|
||||
|
||||
// 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
|
||||
// GetNoteApi returns contents of a note specified in URL.
|
||||
func GetNoteApi(w http.ResponseWriter, r *http.Request) {
|
||||
noteString := chi.URLParam(r, "note")
|
||||
if noteString == "" {
|
||||
|
@ -93,10 +83,10 @@ func GetNoteApi(w http.ResponseWriter, r *http.Request) {
|
|||
HandleWrite(w.Write([]byte("note not specified")))
|
||||
return
|
||||
}
|
||||
GetFile(DataFile("notes/"+noteString), w)
|
||||
GetFileApi(DataFile("notes/"+noteString), w)
|
||||
}
|
||||
|
||||
// PostNoteApi writes request's body contents to a note specified in URL
|
||||
// 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 == "" {
|
||||
|
@ -104,10 +94,10 @@ func PostNoteApi(w http.ResponseWriter, r *http.Request) {
|
|||
HandleWrite(w.Write([]byte("note not specified")))
|
||||
return
|
||||
}
|
||||
PostFile(DataFile("notes/"+noteString), w, r)
|
||||
PostFileApi(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) {
|
||||
value := "false"
|
||||
if GraceActive() {
|
||||
|
@ -116,23 +106,3 @@ func GraceActiveApi(w http.ResponseWriter, r *http.Request) {
|
|||
HandleWrite(w.Write([]byte(value)))
|
||||
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
16
auth.go
|
@ -19,10 +19,10 @@ type failedLogin struct {
|
|||
|
||||
var failedLogins []failedLogin
|
||||
|
||||
// NoteLoginFail attempts to log and counteract bruteforce/spam attacks
|
||||
// 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_notification")+":\nusername=%s\npassword=%s\nremote=%s", username, password, 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}
|
||||
|
@ -40,8 +40,8 @@ func NoteLoginFail(username string, password string, r *http.Request) {
|
|||
}
|
||||
|
||||
// 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)
|
||||
// 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()
|
||||
|
@ -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() {
|
||||
slog.Warn("SCRAM triggered, shutting down")
|
||||
NotifyTelegram("Hibiscus SCRAM triggered, shutting down")
|
||||
os.Exit(0) // TODO: should this be 0 or 1?
|
||||
NotifyTelegram(TranslatableText("info.telegram.scram"))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// NotifyTelegram attempts to send a message to admin through Telegram
|
||||
// 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")
|
||||
|
|
40
config.go
40
config.go
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
@ -51,7 +52,7 @@ var DefaultConfig = Config{
|
|||
TelegramTopic: "",
|
||||
}
|
||||
|
||||
// String returns text version of modified and mandatory config options
|
||||
// String returns string representation of modified and mandatory config options.
|
||||
func (c *Config) String() string {
|
||||
output := ""
|
||||
v := reflect.ValueOf(*c)
|
||||
|
@ -68,11 +69,13 @@ func (c *Config) String() string {
|
|||
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 {
|
||||
*c = DefaultConfig // Reset config
|
||||
|
||||
if _, err := os.Stat(ConfigFile); errors.Is(err, os.ErrNotExist) {
|
||||
err := c.Save([]byte(c.String()))
|
||||
err := c.Save()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -149,17 +152,40 @@ func (c *Config) Reload() error {
|
|||
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) {
|
||||
return ReadFile(ConfigFile)
|
||||
}
|
||||
|
||||
// Save writes to ConfigFile
|
||||
func (c *Config) Save(contents []byte) error {
|
||||
return SaveFile(ConfigFile, contents)
|
||||
// Save writes config's contents to the ConfigFile.
|
||||
func (c *Config) Save() error {
|
||||
return SaveFile(ConfigFile, []byte(c.String()))
|
||||
}
|
||||
|
||||
// ConfigInit loads config on startup
|
||||
// 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"), 302)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ConfigInit loads config on startup.
|
||||
func ConfigInit() Config {
|
||||
cfg := Config{}
|
||||
err := cfg.Reload()
|
||||
|
|
|
@ -2,4 +2,4 @@ username=admin
|
|||
password=admin
|
||||
port=7101
|
||||
timezone=Local
|
||||
language=en
|
||||
language=en
|
|
@ -11,7 +11,7 @@ import (
|
|||
|
||||
var ExportPath = "data/export.zip"
|
||||
|
||||
// Export saves a .zip archive of the data folder to the passed filename
|
||||
// Export saves a .zip archive of the data folder to a file.
|
||||
func Export(filename string) error {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
|
@ -61,7 +61,8 @@ func Export(filename string) error {
|
|||
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) {
|
||||
err := Export(ExportPath)
|
||||
if err != nil {
|
||||
|
|
38
files.go
38
files.go
|
@ -11,34 +11,30 @@ import (
|
|||
"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 {
|
||||
return "data/" + path.Clean(filename) + ".txt"
|
||||
}
|
||||
|
||||
// ReadFile returns raw contents of a file
|
||||
// ReadFile returns contents of a file.
|
||||
func ReadFile(filename string) ([]byte, error) {
|
||||
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
fileContents, err := os.ReadFile(filename)
|
||||
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 fileContents, nil
|
||||
}
|
||||
|
||||
// SaveFile Writes contents to a file
|
||||
// SaveFile Writes contents to a file.
|
||||
func SaveFile(filename string, contents []byte) error {
|
||||
contents = bytes.TrimSpace(contents)
|
||||
if len(contents) == 0 { // Delete empty files
|
||||
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
|
||||
}
|
||||
err := os.MkdirAll(path.Dir(filename), 0755) // Create dir in case it doesn't exist yet to avoid errors
|
||||
|
@ -48,21 +44,17 @@ func SaveFile(filename string, contents []byte) error {
|
|||
}
|
||||
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
slog.Error("error opening/making file",
|
||||
"error", err,
|
||||
"file", filename)
|
||||
slog.Error("error opening/creating file", "error", err, "file", filename)
|
||||
return err
|
||||
}
|
||||
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 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?
|
||||
func ListFiles(directory string) ([]string, error) {
|
||||
filenames, err := filepath.Glob("data/" + path.Clean(directory) + "/*.txt")
|
||||
|
@ -76,7 +68,7 @@ func ListFiles(directory string) ([]string, error) {
|
|||
return filenames, nil
|
||||
}
|
||||
|
||||
// GraceActive returns whether the grace period (Cfg.GraceTime) is active
|
||||
// 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())
|
||||
|
@ -88,7 +80,7 @@ func GraceActive() bool {
|
|||
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 {
|
||||
dateFormatted := time.Now().In(Cfg.Timezone).Format(time.DateOnly)
|
||||
if GraceActive() {
|
||||
|
@ -97,13 +89,3 @@ func TodayDate() string {
|
|||
slog.Debug("today", "time", time.Now().In(Cfg.Timezone).Format(time.DateTime))
|
||||
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)
|
||||
}
|
||||
|
|
2
flags.go
2
flags.go
|
@ -5,7 +5,7 @@ import (
|
|||
"log"
|
||||
)
|
||||
|
||||
// FlagInit processes app flags
|
||||
// FlagInit processes app flags.
|
||||
func FlagInit() {
|
||||
config := flag.String("config", "", "override config file")
|
||||
username := flag.String("user", "", "override username")
|
||||
|
|
7
i18n.go
7
i18n.go
|
@ -10,7 +10,7 @@ import (
|
|||
var I18n 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
|
||||
// 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"
|
||||
|
@ -23,14 +23,15 @@ func SetLanguage(language string) error {
|
|||
}
|
||||
return json.Unmarshal(fileContents, &Translations)
|
||||
}
|
||||
err := loadLanguage("en") // Load english as fallback language
|
||||
Translations = map[string]string{} // Clear the map to avoid previous language remaining
|
||||
err := loadLanguage("en") // Load English as fallback
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
if v, ok := Translations[id]; !ok {
|
||||
return id
|
||||
|
|
|
@ -24,5 +24,6 @@
|
|||
"info.readme": "Edit readme.txt",
|
||||
"info.config": "Edit config",
|
||||
|
||||
"info.telegram_notification": "Failed auth attempt in Hibiscus.txt"
|
||||
"info.telegram.auth_fail": "Failed auth attempt in Hibiscus.txt",
|
||||
"info.telegram.scram": "Hibiscus SCRAM triggered, shutting down"
|
||||
}
|
|
@ -24,5 +24,6 @@
|
|||
"info.readme": "Редактировать readme.txt",
|
||||
"info.config": "Редактировать конфиг",
|
||||
|
||||
"info.telegram_notification": "Неверная попытка авторизации в Hibiscus.txt"
|
||||
"info.telegram_notification": "Неверная попытка авторизации в Hibiscus.txt",
|
||||
"info.telegram.scram": "Активирована функция SCRAM в Hibiscus.txt, сервер выключается"
|
||||
}
|
12
info.go
12
info.go
|
@ -13,13 +13,13 @@ type AppInfo struct {
|
|||
SourceLink string
|
||||
}
|
||||
|
||||
// Info contains app information
|
||||
// Info contains app information.
|
||||
var Info = AppInfo{
|
||||
Version: "1.1.2",
|
||||
Version: "1.1.3",
|
||||
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) {
|
||||
err := infoTemplate.ExecuteTemplate(w, "base", Info)
|
||||
if err != nil {
|
||||
|
@ -28,3 +28,9 @@ func GetInfo(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GetVersionApi returns current app version.
|
||||
func GetVersionApi(w http.ResponseWriter, r *http.Request) {
|
||||
HandleWrite(w.Write([]byte(Info.Version)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
var DebugMode = false
|
||||
|
||||
// LogInit makes slog output to both stdout and a file if needed, and enables debug mode if selected
|
||||
// LogInit makes slog output to both os.Stdout and a file if needed, and sets slog.LevelDebug if enabled.
|
||||
func LogInit() {
|
||||
var w io.Writer
|
||||
if Cfg.LogToFile {
|
||||
|
|
112
routes.go
112
routes.go
|
@ -27,17 +27,17 @@ type Entry struct {
|
|||
|
||||
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
|
||||
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
|
||||
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 {
|
||||
data, err := Pages.ReadFile(name)
|
||||
if err != nil {
|
||||
|
@ -55,49 +55,19 @@ 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 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) {
|
||||
w.WriteHeader(404)
|
||||
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) {
|
||||
w.WriteHeader(500)
|
||||
HandleWrite(w.Write(EmbeddedPage("pages/error/500.html")))
|
||||
}
|
||||
|
||||
// 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
|
||||
// GetEntries handles showing a list.
|
||||
func GetEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) {
|
||||
filesList, err := ListFiles(dir)
|
||||
if err != nil {
|
||||
|
@ -115,10 +85,10 @@ func GetEntries(w http.ResponseWriter, r *http.Request, title string, descriptio
|
|||
}
|
||||
}
|
||||
|
||||
// GetDays renders HTML list of previous days' entries
|
||||
// GetDays calls GetEntries for previous days' entries.
|
||||
func GetDays(w http.ResponseWriter, r *http.Request) {
|
||||
description := template.HTML(
|
||||
"<a href=\"#footer\">" + TranslatableText("prompt.days") + "</a>")
|
||||
"<a href=\"#footer\">" + template.HTMLEscapeString(TranslatableText("prompt.days")) + "</a>")
|
||||
GetEntries(w, r, TranslatableText("title.days"), description, "day", func(files []string) []Entry {
|
||||
var filesFormatted []Entry
|
||||
for i := range files {
|
||||
|
@ -145,23 +115,23 @@ func GetDays(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
// GetNotes renders HTML list of all notes
|
||||
// GetNotes calls GetEntries for all notes.
|
||||
func GetNotes(w http.ResponseWriter, r *http.Request) {
|
||||
// This is suboptimal, but will do...
|
||||
description := template.HTML(
|
||||
"<a href=\"#\" onclick='newNote(\"" + TranslatableText("prompt.notes") + "\")'>" + TranslatableText("button.notes") + "</a>" +
|
||||
"<a href=\"#\" onclick='newNote(\"" + template.HTMLEscapeString(TranslatableText("prompt.notes")) + "\")'>" + template.HTMLEscapeString(TranslatableText("button.notes")) + "</a>" +
|
||||
" <noscript>(" + template.HTMLEscapeString(TranslatableText("noscript.notes")) + ")</noscript>")
|
||||
GetEntries(w, r, TranslatableText("title.notes"), description, "notes", func(files []string) []Entry {
|
||||
var filesFormatted []Entry
|
||||
for _, v := range files {
|
||||
titleString := strings.Replace(v, "-", " ", -1) // FIXME: what if I need a hyphen?
|
||||
filesFormatted = append(filesFormatted, Entry{Title: titleString, Link: "notes/" + v})
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
entry, err := ReadFile(filename)
|
||||
if err != nil {
|
||||
|
@ -185,7 +155,19 @@ func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename str
|
|||
}
|
||||
}
|
||||
|
||||
// GetDay renders HTML page for a specific day entry
|
||||
// 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 := 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) {
|
||||
dayString := chi.URLParam(r, "day")
|
||||
if dayString == "" {
|
||||
|
@ -207,7 +189,7 @@ func GetDay(w http.ResponseWriter, r *http.Request) {
|
|||
GetEntry(w, r, title, DataFile("day/"+dayString), false)
|
||||
}
|
||||
|
||||
// GetNote renders HTML page for a note
|
||||
// GetNote calls GetEntry for a note.
|
||||
func GetNote(w http.ResponseWriter, r *http.Request) {
|
||||
noteString := chi.URLParam(r, "note")
|
||||
if noteString == "" {
|
||||
|
@ -223,7 +205,7 @@ func GetNote(w http.ResponseWriter, r *http.Request) {
|
|||
GetEntry(w, r, noteString, DataFile("notes/"+noteString), true)
|
||||
}
|
||||
|
||||
// PostNote saves a note form and redirects back to GET
|
||||
// PostNote calls PostEntry for a note.
|
||||
func PostNote(w http.ResponseWriter, r *http.Request) {
|
||||
noteString := chi.URLParam(r, "note")
|
||||
if noteString == "" {
|
||||
|
@ -231,41 +213,5 @@ func PostNote(w http.ResponseWriter, r *http.Request) {
|
|||
HandleWrite(w.Write([]byte("note not specified")))
|
||||
return
|
||||
}
|
||||
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)
|
||||
PostEntry(DataFile("notes/"+noteString), w, r)
|
||||
}
|
||||
|
|
22
serve.go
22
serve.go
|
@ -9,7 +9,7 @@ import (
|
|||
"strconv"
|
||||
)
|
||||
|
||||
// Serve starts the app's web server
|
||||
// Serve starts the app's web server.
|
||||
func Serve() {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RealIP)
|
||||
|
@ -19,32 +19,34 @@ func Serve() {
|
|||
// Routes ==========
|
||||
userRouter := chi.NewRouter()
|
||||
userRouter.Use(BasicAuth)
|
||||
userRouter.Get("/", GetToday)
|
||||
userRouter.Post("/", PostToday)
|
||||
userRouter.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
GetEntry(w, r, TranslatableText("title.today"), DataFile("day/"+TodayDate()), true)
|
||||
})
|
||||
userRouter.Post("/", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+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", GetReadme)
|
||||
userRouter.Post("/readme", PostReadme)
|
||||
userRouter.Get("/config", GetConfig)
|
||||
userRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "readme.txt", DataFile("readme"), true) })
|
||||
userRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("readme"), w, r) })
|
||||
userRouter.Get("/config", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "config.txt", ConfigFile, true) })
|
||||
userRouter.Post("/config", PostConfig)
|
||||
r.Mount("/", userRouter)
|
||||
|
||||
// API =============
|
||||
apiRouter := chi.NewRouter()
|
||||
apiRouter.Use(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("/readme", func(w http.ResponseWriter, r *http.Request) { GetFileApi("readme", w) })
|
||||
apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFileApi("readme", w, r) })
|
||||
apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { GetFileList("day", w) })
|
||||
apiRouter.Get("/day/{day}", GetDayApi)
|
||||
apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { GetFileList("notes", w) })
|
||||
apiRouter.Get("/notes/{note}", GetNoteApi)
|
||||
apiRouter.Post("/notes/{note}", PostNoteApi)
|
||||
apiRouter.Get("/today", GetTodayApi)
|
||||
apiRouter.Post("/today", PostTodayApi)
|
||||
apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { GetFileApi(DataFile("day/"+TodayDate()), w) })
|
||||
apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) })
|
||||
apiRouter.Get("/export", GetExport)
|
||||
apiRouter.Get("/grace", GraceActiveApi)
|
||||
apiRouter.Get("/version", GetVersionApi)
|
||||
|
|
Loading…
Reference in a new issue