Refactor functions and improve comments

This commit is contained in:
Andrew-71 2024-06-17 00:54:55 +03:00
parent f33206c99d
commit 9cab989b78
16 changed files with 148 additions and 205 deletions

View file

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

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:
```
@ -49,7 +49,7 @@ username=admin # Your username
password=admin # Your password
port=7101 # What port to run on (probably leave on 7101 if using docker)
timezone=Local # IANA Time zone database identifier ("UTC", Local", "Europe/Moscow" etc.), Defaults to Local if can't parse.
grace_period=0s # Time after a new day begins, but before the switch to next day's file. e.g. 2h30m - files will change at 2:30am
grace_period=0s # Time after a new day begins, but before the switch to next day's file. e.g. 3h26m - files will change at 3:26am
language=en # ISO-639 language code (available - en, ru)
theme=default # Picked theme (available - default, high-contrast, lavender, gruvbox, sans)
title=🌺 Hibiscus.txt # The text in the header
@ -68,10 +68,10 @@ tg_topic=message_thread_id
The Docker images are hosted via GitHub over at `ghcr.io/andrew-71/hibiscus:<tag>`,
built from the [Dockerfile](./Dockerfile).
This repo contains the [compose.yml](./compose.yml) that I personally use.
*Note: an outdated personally hosted [package](https://git.a71.su/Andrew71/hibiscus/packages) will be provided for some time.*
*Note: an extremely outdated self-hosted [package](https://git.a71.su/Andrew71/hibiscus/packages) will be provided for some time.*
### Executable flags
If you for some reason decide to run plain executable instead of docker, it supports following flags:
If you decide to use plain executable instead of docker, it supports the following flags:
```
-config string
override config file location
@ -86,7 +86,7 @@ If you for some reason decide to run plain executable instead of docker, it supp
```
### API methods
You can access the API at `/api/<method>`. They are protected by same HTTP Basic Auth as "normal" site.
You can access the API at `/api/<method>`. It is protected by same HTTP Basic Auth as "normal" routes.
```
GET /today - get file contents for today
POST /today - save request body into today's file

56
api.go
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"
@ -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

@ -24,5 +24,6 @@
"info.readme": "Edit readme.txt",
"info.config": "Edit config",
"info.telegram_notification": "Failed auth attempt in Hibiscus.txt"
"info.telegram.auth_fail": "Failed auth attempt in Hibiscus.txt",
"info.telegram.scram": "Hibiscus SCRAM triggered, shutting down"
}

View file

@ -24,5 +24,6 @@
"info.readme": "Редактировать readme.txt",
"info.config": "Редактировать конфиг",
"info.telegram_notification": "Неверная попытка авторизации в Hibiscus.txt"
"info.telegram_notification": "Неверная попытка авторизации в Hibiscus.txt",
"info.telegram.scram": "Активирована функция SCRAM в Hibiscus.txt, сервер выключается"
}

12
info.go
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.3",
SourceLink: "https://git.a71.su/Andrew71/hibiscus",
}
// GetInfo renders the info page
// GetInfo renders the info page.
func GetInfo(w http.ResponseWriter, r *http.Request) {
err := infoTemplate.ExecuteTemplate(w, "base", Info)
if err != nil {
@ -28,3 +28,9 @@ func GetInfo(w http.ResponseWriter, r *http.Request) {
return
}
}
// GetVersionApi returns current app version.
func GetVersionApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte(Info.Version)))
w.WriteHeader(http.StatusOK)
}

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 {

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)