Compare commits

..

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

24 changed files with 233 additions and 197 deletions

View file

@ -1,29 +1,12 @@
# 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
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 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
* 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

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
* 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
## 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 [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:
```
@ -39,19 +39,19 @@ config
Deleting notes is done by clearing contents and clicking "Save" - the app deletes empty files when saving.
### Config options:
Below are the available configuration options and their defaults.
The settings are defined as newline separated `key=value` pairs in the config file.
If you do not provide an option, the default will be used.
Below are available configuration options and their defaults.
The settings are defined as newline separated key=value pairs in config.txt.
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,
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
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. 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)
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
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
@ -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 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
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
override config file location
@ -86,7 +86,7 @@ If you decide to use plain executable instead of docker, it supports the followi
```
### 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
POST /today - save request body into today's file

View file

@ -1,7 +1,6 @@
# TODO
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
## Brainstorming

56
api.go
View file

@ -10,15 +10,15 @@ import (
"os"
)
// HandleWrite handles error in output of ResponseWriter.Write.
// HandleWrite checks for error in ResponseWriter.Write output
func HandleWrite(_ int, err error) {
if err != nil {
slog.Error("error writing response", "error", err)
}
}
// GetFileApi returns raw contents of a file.
func GetFileApi(filename string, w http.ResponseWriter) {
// GetFile returns raw contents of a file
func GetFile(filename string, w http.ResponseWriter) {
fileContents, err := ReadFile(filename)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@ -31,8 +31,8 @@ func GetFileApi(filename string, w http.ResponseWriter) {
HandleWrite(w.Write(fileContents))
}
// PostFileApi writes contents of Request.Body to a file.
func PostFileApi(filename string, w http.ResponseWriter, r *http.Request) {
// PostFile writes request's body contents to a file
func PostFile(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 PostFileApi(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 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) {
dayString := chi.URLParam(r, "day")
if dayString == "" {
@ -72,10 +72,20 @@ func GetDayApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("day not specified")))
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) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
@ -83,10 +93,10 @@ func GetNoteApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified")))
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) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
@ -94,10 +104,10 @@ func PostNoteApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified")))
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) {
value := "false"
if GraceActive() {
@ -106,3 +116,23 @@ 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
View file

@ -19,10 +19,10 @@ type failedLogin struct {
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) {
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()}
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(TranslatableText("info.telegram.scram"))
os.Exit(0)
NotifyTelegram("Hibiscus SCRAM triggered, shutting down")
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) {
if Cfg.TelegramChat == "" || Cfg.TelegramToken == "" {
slog.Debug("ignoring telegram request due to lack of credentials")

View file

@ -6,7 +6,6 @@ import (
"fmt"
"log"
"log/slog"
"net/http"
"os"
"reflect"
"strconv"
@ -41,7 +40,7 @@ var DefaultConfig = Config{
Timezone: time.Local,
GraceTime: 0,
Language: "en",
Theme: "",
Theme: "default",
Title: "🌺 Hibiscus.txt",
LogToFile: false,
LogFile: "config/log.txt",
@ -49,10 +48,9 @@ var DefaultConfig = Config{
TelegramToken: "",
TelegramChat: "",
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 {
output := ""
v := reflect.ValueOf(*c)
@ -69,13 +67,11 @@ 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()
err := c.Save([]byte(c.String()))
if err != nil {
return err
}
@ -152,40 +148,17 @@ 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 config's contents to the ConfigFile.
func (c *Config) Save() error {
return SaveFile(ConfigFile, []byte(c.String()))
// Save writes to ConfigFile
func (c *Config) Save(contents []byte) error {
return SaveFile(ConfigFile, contents)
}
// 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.
// ConfigInit loads config on startup
func ConfigInit() Config {
cfg := Config{}
err := cfg.Reload()

View file

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

View file

@ -11,30 +11,34 @@ 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 contents of a file.
// ReadFile returns raw 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
@ -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)
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
}
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")
@ -68,7 +76,7 @@ func ListFiles(directory string) ([]string, error) {
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 {
t := time.Now().In(Cfg.Timezone)
active := (60*t.Hour() + t.Minute()) < int(Cfg.GraceTime.Minutes())
@ -80,7 +88,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() {
@ -89,3 +97,13 @@ 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)
}

View file

@ -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")

View file

@ -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,15 +23,14 @@ func SetLanguage(language string) error {
}
return json.Unmarshal(fileContents, &Translations)
}
Translations = map[string]string{} // Clear the map to avoid previous language remaining
err := loadLanguage("en") // Load English as fallback
err := loadLanguage("en") // Load english as fallback language
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

View file

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

View file

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

12
info.go
View file

@ -13,13 +13,13 @@ type AppInfo struct {
SourceLink string
}
// Info contains app information.
// Info contains app information
var Info = AppInfo{
Version: "1.1.4",
Version: "1.1.1",
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,9 +28,3 @@ 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)
}

View file

@ -10,7 +10,7 @@ import (
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() {
var w io.Writer
if Cfg.LogToFile {

View file

@ -1,11 +1,11 @@
{{ define "header" }}
{{define "header"}}
<header>
<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>
{{ end }}
{{end}}
{{- define "base" -}}
{{define "base"}}
<!DOCTYPE html>
<html lang="{{ translatableText "lang" }}">
<head>
@ -14,16 +14,16 @@
<link rel="manifest" href="/public/manifest.json" />
<link rel="icon" type="image/x-icon" href="/public/favicon.ico">
<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>
<title>Hibiscus.txt</title>
</head>
<body>
{{- template "header" . -}}
{{template "header" .}}
<main>
{{- template "main" . -}}
{{template "main" .}}
</main>
{{- template "footer" . -}}
{{template "footer" .}}
<script defer>
const langName="{{ config.Language }}";
const timeZone="{{ config.Timezone }}";
@ -31,11 +31,11 @@
</script>
</body>
</html>
{{ end }}
{{end}}
{{ define "footer" }}
{{define "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>
<span style="float:right;"><a class="no-accent" href="/info" title="{{ translatableText "link.info" }}">v{{ info.Version }}</a></span></p>
</footer>
{{ end }}
{{end}}

View file

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

View file

@ -1,4 +1,4 @@
{{ define "main" }}
{{define "main"}}
<h2><label for="text">{{ .Title }}</label></h2>
<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>
<ul>
<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="/api/export" download="hibiscus">{{ translatableText "info.export" }}</a></li>
</ul>
{{ end }}
{{end}}

View file

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

View file

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

View file

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

120
routes.go
View file

@ -27,21 +27,17 @@ type Entry struct {
type formatEntries func([]string) []Entry
// 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.
//
//go:embed pages
var Pages embed.FS
// EmbeddedPage returns contents of a file in Pages while "handling" potential errors.
func EmbeddedPage(name string) []byte {
// EmbeddedFile returns a file in Pages while "handling" potential errors
func EmbeddedFile(name string) []byte {
data, err := Pages.ReadFile(name)
if err != nil {
slog.Error("error reading embedded file", "err", err)
slog.Error("Error embedded file", "err", err)
}
return data
}
@ -55,19 +51,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 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")))
HandleWrite(w.Write(EmbeddedFile("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")))
HandleWrite(w.Write(EmbeddedFile("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) {
filesList, err := ListFiles(dir)
if err != nil {
@ -85,10 +111,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) {
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 {
var filesFormatted []Entry
for i := range files {
@ -115,23 +141,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) {
// This is suboptimal, but will do...
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>")
GetEntries(w, r, TranslatableText("title.notes"), description, "notes", func(files []string) []Entry {
var filesFormatted []Entry
for _, v := range files {
// 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})
titleString := strings.Replace(v, "-", " ", -1) // FIXME: what if I need a hyphen?
filesFormatted = append(filesFormatted, Entry{Title: titleString, 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 {
@ -155,19 +181,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.
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.
// GetDay renders HTML page for a specific day entry
func GetDay(w http.ResponseWriter, r *http.Request) {
dayString := chi.URLParam(r, "day")
if dayString == "" {
@ -189,7 +203,7 @@ func GetDay(w http.ResponseWriter, r *http.Request) {
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) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
@ -205,7 +219,7 @@ func GetNote(w http.ResponseWriter, r *http.Request) {
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) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
@ -213,5 +227,41 @@ func PostNote(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified")))
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,44 +9,41 @@ 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)
r.Use(middleware.Logger, middleware.CleanPath, middleware.StripSlashes)
r.NotFound(NotFound)
// Routes ==========
userRouter := chi.NewRouter()
userRouter.Use(BasicAuth)
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("/", GetToday)
userRouter.Post("/", PostToday)
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", 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.Get("/readme", GetReadme)
userRouter.Post("/readme", PostReadme)
userRouter.Get("/config", GetConfig)
userRouter.Post("/config", PostConfig)
r.Mount("/", userRouter)
// API =============
apiRouter := chi.NewRouter()
apiRouter.Use(BasicAuth)
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("/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("/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", 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("/today", GetTodayApi)
apiRouter.Post("/today", PostTodayApi)
apiRouter.Get("/export", GetExport)
apiRouter.Get("/grace", GraceActiveApi)
apiRouter.Get("/version", GetVersionApi)