Compare commits

...

3 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
23 changed files with 187 additions and 235 deletions

View file

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

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 (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:
```
@ -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 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.
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.
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
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)
theme="" # Picked theme (available - default (if left empty), 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 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

View file

@ -1,6 +1,7 @@
# 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 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
View file

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

View file

@ -6,6 +6,7 @@ import (
"fmt"
"log"
"log/slog"
"net/http"
"os"
"reflect"
"strconv"
@ -40,7 +41,7 @@ var DefaultConfig = Config{
Timezone: time.Local,
GraceTime: 0,
Language: "en",
Theme: "default",
Theme: "",
Title: "🌺 Hibiscus.txt",
LogToFile: false,
LogFile: "config/log.txt",
@ -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()

View file

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

View file

@ -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 {

View file

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

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,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

View file

@ -1,4 +1,6 @@
{
"lang": "en-UK",
"title.today": "Your day so far",
"title.days": "Previous days",
"title.notes": "Notes",
@ -24,5 +26,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"
}

View file

@ -1,4 +1,6 @@
{
"lang": "ru",
"title.today": "Сегодняшний день",
"title.days": "Предыдущие дни",
"title.notes": "Заметки",
@ -11,7 +13,7 @@
"link.info": "системная информация",
"time.date": "Сегодня",
"time.grace": "льготный период",
"time.grace": "редактируется вчерашний день",
"button.save": "Сохранить",
"button.notes": "Новая заметка",
"prompt.notes": "Название заметки",
@ -24,5 +26,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
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.2",
Version: "1.1.4",
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)
}

View file

@ -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 {

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

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

112
routes.go
View file

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

View file

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