Compare commits

...

7 commits

Author SHA1 Message Date
8ae76cc8e8 Fix language tag and improve templates 2024-08-28 14:53:54 +03:00
2b0f9c139a Improve README.md 2024-08-06 00:51:05 +03:00
9cab989b78 Refactor functions and improve comments 2024-06-17 00:54:55 +03:00
f33206c99d Improve comments for embedded filesystems 2024-06-02 12:27:36 +03:00
Andrey N
ada165fe29
Merge pull request #2
Log real IP address and fix textarea padding in Safari
2024-06-02 12:07:09 +03:00
Rithas K
e5962ebfe7
Fix textarea padding in Safari
Use `box-sizing: border-box` to include padding in the width of the textarea.

Also,
> Box-sizing should be border-box by default
from [csswg](https://wiki.csswg.org/ideas/mistakes)
2024-06-02 11:53:40 +05:30
Rithas K
408d244471
Log real IP address
Log real IP address of the client when accessed through a reverse proxy
2024-06-02 10:29:11 +05:30
24 changed files with 197 additions and 233 deletions

View file

@ -1,12 +1,29 @@
# Changelog # 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.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
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 ## 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 ## 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 (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 * 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 [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: ### 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 available configuration options and their defaults. Below are the available configuration options and their defaults.
The settings are defined as newline separated key=value pairs in config.txt. The settings are defined as newline separated `key=value` pairs in the config file.
If you do not provide an option in your config, it will be using the default. If you do not provide an option, the default will be used.
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 only and **will break the config if present**. they are provided purely for demonstration 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. 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) language=en # ISO-639 language code (available - en, ru)
theme=default # Picked theme (available - default, high-contrast, lavender, gruvbox, sans) theme="" # Picked theme (available - default (if left empty), 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 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 ### 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 -config string
override config file location 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 ### 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 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,6 +1,7 @@
# 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 checks for error in ResponseWriter.Write output // HandleWrite handles error in output of ResponseWriter.Write.
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)
} }
} }
// GetFile returns raw contents of a file // GetFileApi returns raw contents of a file.
func GetFile(filename string, w http.ResponseWriter) { func GetFileApi(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 GetFile(filename string, w http.ResponseWriter) {
HandleWrite(w.Write(fileContents)) HandleWrite(w.Write(fileContents))
} }
// PostFile writes request's body contents to a file // PostFileApi writes contents of Request.Body to a file.
func PostFile(filename string, w http.ResponseWriter, r *http.Request) { func PostFileApi(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 PostFile(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 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) { func GetDayApi(w http.ResponseWriter, r *http.Request) {
dayString := chi.URLParam(r, "day") dayString := chi.URLParam(r, "day")
if dayString == "" { if dayString == "" {
@ -72,20 +72,10 @@ 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(DataFile("day/"+dayString), w) GetFileApi(DataFile("day/"+dayString), w)
} }
// GetTodayApi runs GetFile with today's date as filename // GetNoteApi returns contents of a note specified in URL.
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 == "" {
@ -93,10 +83,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
} }
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) { func PostNoteApi(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note") noteString := chi.URLParam(r, "note")
if noteString == "" { if noteString == "" {
@ -104,10 +94,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
} }
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) { func GraceActiveApi(w http.ResponseWriter, r *http.Request) {
value := "false" value := "false"
if GraceActive() { if GraceActive() {
@ -116,23 +106,3 @@ 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/spam attacks // NoteLoginFail attempts to log and counteract bruteforce 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_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()} 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("Hibiscus SCRAM triggered, shutting down") NotifyTelegram(TranslatableText("info.telegram.scram"))
os.Exit(0) // TODO: should this be 0 or 1? 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) { 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,6 +6,7 @@ import (
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"net/http"
"os" "os"
"reflect" "reflect"
"strconv" "strconv"
@ -40,7 +41,7 @@ var DefaultConfig = Config{
Timezone: time.Local, Timezone: time.Local,
GraceTime: 0, GraceTime: 0,
Language: "en", Language: "en",
Theme: "default", Theme: "",
Title: "🌺 Hibiscus.txt", Title: "🌺 Hibiscus.txt",
LogToFile: false, LogToFile: false,
LogFile: "config/log.txt", LogFile: "config/log.txt",
@ -48,9 +49,10 @@ var DefaultConfig = Config{
TelegramToken: "", TelegramToken: "",
TelegramChat: "", TelegramChat: "",
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 { func (c *Config) String() string {
output := "" output := ""
v := reflect.ValueOf(*c) v := reflect.ValueOf(*c)
@ -67,11 +69,13 @@ 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([]byte(c.String())) err := c.Save()
if err != nil { if err != nil {
return err return err
} }
@ -148,17 +152,40 @@ 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 to ConfigFile // Save writes config's contents to the ConfigFile.
func (c *Config) Save(contents []byte) error { func (c *Config) Save() error {
return SaveFile(ConfigFile, contents) 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 { func ConfigInit() Config {
cfg := Config{} cfg := Config{}
err := cfg.Reload() err := cfg.Reload()

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 the passed filename // Export saves a .zip archive of the data folder to a file.
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,7 +61,8 @@ 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,34 +11,30 @@ 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 raw contents of a file // ReadFile returns 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", slog.Error("error reading file", "error", err, "file", filename)
"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", slog.Error("error deleting empty file", "error", err, "file", filename)
"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
@ -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) 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/making file", slog.Error("error opening/creating file", "error", err, "file", filename)
"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", slog.Error("error writing to file", "error", err, "file", filename)
"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")
@ -76,7 +68,7 @@ func ListFiles(directory string) ([]string, error) {
return filenames, nil 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 { 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())
@ -88,7 +80,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() {
@ -97,13 +89,3 @@ 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,14 +23,15 @@ func SetLanguage(language string) error {
} }
return json.Unmarshal(fileContents, &Translations) 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 { 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,4 +1,6 @@
{ {
"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",
@ -24,5 +26,6 @@
"info.readme": "Edit readme.txt", "info.readme": "Edit readme.txt",
"info.config": "Edit config", "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"
} }

View file

@ -1,4 +1,6 @@
{ {
"lang": "ru",
"title.today": "Сегодняшний день", "title.today": "Сегодняшний день",
"title.days": "Предыдущие дни", "title.days": "Предыдущие дни",
"title.notes": "Заметки", "title.notes": "Заметки",
@ -11,7 +13,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": "Название заметки",
@ -24,5 +26,6 @@
"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.1", Version: "1.1.4",
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,3 +28,9 @@ 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 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() { func LogInit() {
var w io.Writer var w io.Writer
if Cfg.LogToFile { if Cfg.LogToFile {

View file

@ -5,7 +5,7 @@
</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">
<link rel="stylesheet" href="/public/themes/{{ config.Theme }}.css"> {{- if config.Theme -}}<link rel="stylesheet" href="/public/themes/{{ config.Theme }}.css">{{ end }}
<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 }}";

View file

@ -25,6 +25,7 @@
--textarea-border-dark: #454545; --textarea-border-dark: #454545;
} }
* { box-sizing: border-box; }
body { body {
color: var(--text-light); color: var(--text-light);
background-color: var(--bg-light); background-color: var(--bg-light);

View file

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

120
routes.go
View file

@ -27,17 +27,21 @@ type Entry struct {
type formatEntries func([]string) []Entry type formatEntries func([]string) []Entry
// 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.
//
//go:embed pages //go:embed pages
var Pages embed.FS var Pages embed.FS
// EmbeddedFile returns a file in Pages while "handling" potential errors // EmbeddedPage returns contents of a file in Pages while "handling" potential errors.
func EmbeddedFile(name string) []byte { func EmbeddedPage(name string) []byte {
data, err := Pages.ReadFile(name) data, err := Pages.ReadFile(name)
if err != nil { if err != nil {
slog.Error("Error embedded file", "err", err) slog.Error("error reading embedded file", "err", err)
} }
return data return data
} }
@ -51,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 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(EmbeddedFile("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(EmbeddedFile("pages/error/500.html"))) HandleWrite(w.Write(EmbeddedPage("pages/error/500.html")))
} }
// GetToday renders HTML page for today's entry // GetEntries handles showing a list.
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 {
@ -111,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) { func GetDays(w http.ResponseWriter, r *http.Request) {
description := template.HTML( 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 { 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 {
@ -141,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) { 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(\"" + 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>") " <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) // FIXME: what if I need a hyphen? // titleString := strings.Replace(v, "-", " ", -1) // This would be cool, but what if I need a hyphen?
filesFormatted = append(filesFormatted, Entry{Title: titleString, Link: "notes/" + v}) filesFormatted = append(filesFormatted, Entry{Title: v, 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 {
@ -181,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) { func GetDay(w http.ResponseWriter, r *http.Request) {
dayString := chi.URLParam(r, "day") dayString := chi.URLParam(r, "day")
if dayString == "" { if dayString == "" {
@ -203,7 +189,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 renders HTML page for a note // GetNote calls GetEntry 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 == "" {
@ -219,7 +205,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 saves a note form and redirects back to GET // PostNote calls PostEntry for a note.
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 == "" {
@ -227,41 +213,5 @@ 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(DataFile("notes/"+noteString), []byte(r.FormValue("text"))) PostEntry(DataFile("notes/"+noteString), w, r)
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,41 +9,44 @@ 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.Logger, middleware.CleanPath, middleware.StripSlashes) r.Use(middleware.Logger, middleware.CleanPath, middleware.StripSlashes)
r.NotFound(NotFound) r.NotFound(NotFound)
// Routes ========== // Routes ==========
userRouter := chi.NewRouter() userRouter := chi.NewRouter()
userRouter.Use(BasicAuth) userRouter.Use(BasicAuth)
userRouter.Get("/", GetToday) userRouter.Get("/", func(w http.ResponseWriter, r *http.Request) {
userRouter.Post("/", PostToday) 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", 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", GetReadme) userRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "readme.txt", DataFile("readme"), true) })
userRouter.Post("/readme", PostReadme) userRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("readme"), w, r) })
userRouter.Get("/config", GetConfig) userRouter.Get("/config", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "config.txt", ConfigFile, true) })
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) { GetFile("readme", w) }) apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFileApi("readme", w) })
apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFile("readme", w, r) }) 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", 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", GetTodayApi) apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { GetFileApi(DataFile("day/"+TodayDate()), w) })
apiRouter.Post("/today", PostTodayApi) apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) })
apiRouter.Get("/export", GetExport) apiRouter.Get("/export", GetExport)
apiRouter.Get("/grace", GraceActiveApi) apiRouter.Get("/grace", GraceActiveApi)
apiRouter.Get("/version", GetVersionApi) apiRouter.Get("/version", GetVersionApi)