Refactor everything

This commit is contained in:
Andrew-71 2024-10-21 16:46:25 +03:00
parent b56ce43c80
commit 57903d4724
45 changed files with 514 additions and 416 deletions

1
.gitignore vendored
View file

@ -27,3 +27,4 @@ hibiscus-txt
data/ data/
config/log.txt config/log.txt
config/dev-config.txt config/dev-config.txt
config/style.css

View file

@ -2,8 +2,10 @@
This file keeps track of changes in a human-readable fashion This file keeps track of changes in a human-readable fashion
## Upcoming ## Upcoming
These changes were not yet released
These changes are not yet released
* Fully refactored app internally
* Adjusted default theme * Adjusted default theme
* Error pages are now translated * Error pages are now translated

4
go.mod
View file

@ -1,5 +1,5 @@
module hibiscus-txt module git.a71.su/Andrew71/hibiscus-txt
go 1.22 go 1.22
require github.com/go-chi/chi/v5 v5.0.12 require github.com/go-chi/chi/v5 v5.1.0

2
go.sum
View file

@ -1,2 +1,4 @@
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=

36
info.go
View file

@ -1,36 +0,0 @@
package main
import (
"html/template"
"log/slog"
"net/http"
)
var infoTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/info.html"))
type AppInfo struct {
Version string
SourceLink string
}
// Info contains app information.
var Info = AppInfo{
Version: "1.1.4",
SourceLink: "https://git.a71.su/Andrew71/hibiscus",
}
// GetInfo renders the info page.
func GetInfo(w http.ResponseWriter, r *http.Request) {
err := infoTemplate.ExecuteTemplate(w, "base", Info)
if err != nil {
slog.Error("error executing template", "error", err)
InternalError(w, r)
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

@ -1,34 +1,37 @@
package main package app
import ( import (
"flag" "flag"
"log" "log"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
"git.a71.su/Andrew71/hibiscus-txt/internal/logging"
) )
// FlagInit processes app flags. // FlagInit processes app flags.
func FlagInit() { func FlagInit() {
config := flag.String("config", "", "override config file") conf := flag.String("config", "", "override config file")
username := flag.String("user", "", "override username") username := flag.String("user", "", "override username")
password := flag.String("pass", "", "override password") password := flag.String("pass", "", "override password")
port := flag.Int("port", 0, "override port") port := flag.Int("port", 0, "override port")
debug := flag.Bool("debug", false, "debug logging") debug := flag.Bool("debug", false, "debug logging")
flag.Parse() flag.Parse()
if *config != "" { if *conf != "" {
ConfigFile = *config config.ConfigFile = *conf
err := Cfg.Reload() err := config.Cfg.Reload()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
if *username != "" { if *username != "" {
Cfg.Username = *username config.Cfg.Username = *username
} }
if *password != "" { if *password != "" {
Cfg.Password = *password config.Cfg.Password = *password
} }
if *port != 0 { if *port != 0 {
Cfg.Port = *port config.Cfg.Port = *port
} }
DebugMode = *debug logging.DebugMode = *debug
} }

12
internal/app/main.go Normal file
View file

@ -0,0 +1,12 @@
package app
import (
"git.a71.su/Andrew71/hibiscus-txt/internal/logging"
"git.a71.su/Andrew71/hibiscus-txt/internal/server"
)
func Execute() {
FlagInit()
logging.LogInit()
server.Serve()
}

12
internal/config/info.go Normal file
View file

@ -0,0 +1,12 @@
package config
type AppInfo struct {
Version string
SourceLink string
}
// Info contains app information.
var Info = AppInfo{
Version: "2.0.0",
SourceLink: "https://git.a71.su/Andrew71/hibiscus",
}

View file

@ -1,4 +1,4 @@
package main package config
import ( import (
"bufio" "bufio"
@ -6,14 +6,17 @@ import (
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"net/http"
"os" "os"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
) )
var Cfg = ConfigInit()
var ConfigFile = "config/config.txt" var ConfigFile = "config/config.txt"
type Config struct { type Config struct {
@ -143,40 +146,17 @@ func (c *Config) Reload() error {
} }
slog.Debug("reloaded config", "config", c) slog.Debug("reloaded config", "config", c)
return SetLanguage(c.Language) // Load selected language return lang.SetLanguage(c.Language) // Load selected language
} }
// Read gets raw contents from ConfigFile. // Read gets raw contents from ConfigFile.
func (c *Config) Read() ([]byte, error) { func (c *Config) Read() ([]byte, error) {
return ReadFile(ConfigFile) return files.Read(ConfigFile)
} }
// Save writes config's contents to the ConfigFile. // Save writes config's contents to the ConfigFile.
func (c *Config) Save() error { func (c *Config) Save() error {
return SaveFile(ConfigFile, []byte(c.String())) return files.Save(ConfigFile, []byte(c.String()))
}
// 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"), http.StatusFound)
return
}
w.WriteHeader(http.StatusOK)
} }
// ConfigInit loads config on startup. // ConfigInit loads config on startup.

31
internal/config/time.go Normal file
View file

@ -0,0 +1,31 @@
package config
import (
"log/slog"
"time"
)
// FIXME: This probably shouldn't be part of config package
// Grace returns whether the grace period is active.
// NOTE: Grace period has minute precision
func (c Config) Grace() bool {
t := time.Now().In(c.Timezone)
active := (60*t.Hour() + t.Minute()) < int(c.GraceTime.Minutes())
if active {
slog.Debug("grace period active",
"time", 60*t.Hour()+t.Minute(),
"grace", c.GraceTime.Minutes())
}
return active
}
// TodayDate returns today's formatted date. It accounts for Config.GraceTime.
func (c Config) TodayDate() string {
dateFormatted := time.Now().In(c.Timezone).Format(time.DateOnly)
if c.Grace() {
dateFormatted = time.Now().In(c.Timezone).AddDate(0, 0, -1).Format(time.DateOnly)
}
slog.Debug("today", "time", time.Now().In(c.Timezone).Format(time.DateTime))
return dateFormatted
}

View file

@ -1,4 +1,4 @@
package main package files
import ( import (
"archive/zip" "archive/zip"
@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
) )
var ExportPath = "data/export.zip" var ExportPath = "data/export.zip" // TODO: Move to config
// Export saves a .zip archive of the data folder to a file. // Export saves a .zip archive of the data folder to a file.
func Export(filename string) error { func Export(filename string) error {

View file

@ -1,4 +1,4 @@
package main package files
import ( import (
"bytes" "bytes"
@ -8,7 +8,6 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
"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.
@ -16,8 +15,8 @@ func DataFile(filename string) string {
return "data/" + path.Clean(filename) + ".txt" return "data/" + path.Clean(filename) + ".txt"
} }
// ReadFile returns contents of a file. // Read returns contents of a file.
func ReadFile(filename string) ([]byte, error) { func Read(filename string) ([]byte, error) {
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
return nil, err return nil, err
} }
@ -29,8 +28,8 @@ func ReadFile(filename string) ([]byte, error) {
return fileContents, nil return fileContents, nil
} }
// SaveFile Writes contents to a file. // Save Writes contents to a file.
func SaveFile(filename string, contents []byte) error { func Save(filename string, contents []byte) error {
contents = bytes.TrimSpace(contents) contents = bytes.TrimSpace(contents)
if len(contents) == 0 { // Delete empty files if len(contents) == 0 { // Delete empty files
err := os.Remove(filename) err := os.Remove(filename)
@ -54,9 +53,9 @@ func SaveFile(filename string, contents []byte) error {
return nil return nil
} }
// ListFiles returns slice of filenames in a directory without extensions or path. // List returns slice of filenames in a directory without extensions or path.
// NOTE: What if I ever want to list non-text files or those outside data directory? // NOTE: What if I ever want to list non-text files or those outside data directory?
func ListFiles(directory string) ([]string, error) { func List(directory string) ([]string, error) {
filenames, err := filepath.Glob("data/" + path.Clean(directory) + "/*.txt") filenames, err := filepath.Glob("data/" + path.Clean(directory) + "/*.txt")
if err != nil { if err != nil {
return nil, err return nil, err
@ -67,25 +66,3 @@ func ListFiles(directory string) ([]string, error) {
} }
return filenames, nil return filenames, nil
} }
// 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())
if active {
slog.Debug("grace period active",
"time", 60*t.Hour()+t.Minute(),
"grace", Cfg.GraceTime.Minutes())
}
return active
}
// 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() {
dateFormatted = time.Now().In(Cfg.Timezone).AddDate(0, 0, -1).Format(time.DateOnly)
}
slog.Debug("today", "time", time.Now().In(Cfg.Timezone).Format(time.DateTime))
return dateFormatted
}

View file

@ -1,4 +1,4 @@
package main package lang
import ( import (
"embed" "embed"
@ -6,24 +6,24 @@ import (
"log/slog" "log/slog"
) )
//go:embed i18n //go:embed lang
var I18n embed.FS var lang embed.FS
var Translations = map[string]string{} var translations = map[string]string{}
// SetLanguage loads a json file for selected language into the Translations map, with English language as a fallback. // SetLanguage loads a json file for selected language into the Translations map, with English language as a fallback.
func SetLanguage(language string) error { func SetLanguage(language string) error {
loadLanguage := func(language string) error { loadLanguage := func(language string) error {
filename := "i18n/" + language + ".json" filename := "lang/" + language + ".json"
fileContents, err := I18n.ReadFile(filename) fileContents, err := lang.ReadFile(filename)
if err != nil { if err != nil {
slog.Error("error reading language file", slog.Error("error reading language file",
"error", err, "error", err,
"file", filename) "file", filename)
return err return err
} }
return json.Unmarshal(fileContents, &Translations) return json.Unmarshal(fileContents, &translations)
} }
Translations = map[string]string{} // Clear the map to avoid previous language remaining 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
if err != nil { if err != nil {
return err return err
@ -31,9 +31,9 @@ func SetLanguage(language string) error {
return loadLanguage(language) return loadLanguage(language)
} }
// TranslatableText attempts to match an id to a string in current language. // Translate attempts to match an id to a string in current language.
func TranslatableText(id string) string { func Translate(id string) string {
if v, ok := Translations[id]; !ok { if v, ok := translations[id]; !ok {
return id return id
} else { } else {
return v return v

View file

@ -1,11 +1,13 @@
package main package logging
import ( import (
"github.com/go-chi/chi/v5/middleware"
"io" "io"
"log" "log"
"log/slog" "log/slog"
"os" "os"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
"github.com/go-chi/chi/v5/middleware"
) )
var DebugMode = false var DebugMode = false
@ -13,10 +15,10 @@ 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 os.Stdout and a file if needed, and sets slog.LevelDebug if enabled.
func LogInit() { func LogInit() {
var w io.Writer var w io.Writer
if Cfg.LogToFile { if config.Cfg.LogToFile {
f, err := os.OpenFile(Cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) f, err := os.OpenFile(config.Cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil { if err != nil {
slog.Error("error opening log file, logging to stdout", "path", Cfg.LogFile, "error", err) slog.Error("error opening log file, logging to stdout only", "path", config.Cfg.LogFile, "error", err)
return return
} }
// No defer f.Close() because that breaks the MultiWriter // No defer f.Close() because that breaks the MultiWriter

View file

@ -1,13 +1,16 @@
package main package server
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/go-chi/chi/v5"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
"github.com/go-chi/chi/v5"
) )
// HandleWrite handles error in output of ResponseWriter.Write. // HandleWrite handles error in output of ResponseWriter.Write.
@ -19,7 +22,7 @@ func HandleWrite(_ int, err error) {
// GetFileApi returns raw contents of a file. // GetFileApi returns raw contents of a file.
func GetFileApi(filename string, w http.ResponseWriter) { func GetFileApi(filename string, w http.ResponseWriter) {
fileContents, err := ReadFile(filename) fileContents, err := files.Read(filename)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
http.Error(w, "file not found", http.StatusNotFound) http.Error(w, "file not found", http.StatusNotFound)
@ -39,7 +42,7 @@ func PostFileApi(filename string, w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("error reading body"))) HandleWrite(w.Write([]byte("error reading body")))
return return
} }
err = SaveFile(filename, body) err = files.Save(filename, body)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
HandleWrite(w.Write([]byte("error saving file"))) HandleWrite(w.Write([]byte("error saving file")))
@ -51,7 +54,7 @@ func PostFileApi(filename string, w http.ResponseWriter, r *http.Request) {
// GetFileList returns JSON list of filenames in a directory without extensions or path. // GetFileList returns JSON list of filenames in a directory without extensions or path.
func GetFileList(directory string, w http.ResponseWriter) { func GetFileList(directory string, w http.ResponseWriter) {
filenames, err := ListFiles(directory) filenames, err := files.List(directory)
if err != nil { if err != nil {
http.Error(w, "error searching for files", http.StatusInternalServerError) http.Error(w, "error searching for files", http.StatusInternalServerError)
return return
@ -72,7 +75,7 @@ func GetDayApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("day not specified"))) HandleWrite(w.Write([]byte("day not specified")))
return return
} }
GetFileApi(DataFile("day/"+dayString), w) GetFileApi(files.DataFile("day/"+dayString), w)
} }
// GetNoteApi returns contents of a note specified in URL. // GetNoteApi returns contents of a note specified in URL.
@ -83,7 +86,7 @@ func GetNoteApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified"))) HandleWrite(w.Write([]byte("note not specified")))
return return
} }
GetFileApi(DataFile("notes/"+noteString), w) GetFileApi(files.DataFile("notes/"+noteString), w)
} }
// PostNoteApi writes contents of Request.Body to a note specified in URL. // PostNoteApi writes contents of Request.Body to a note specified in URL.
@ -94,15 +97,16 @@ func PostNoteApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte("note not specified"))) HandleWrite(w.Write([]byte("note not specified")))
return return
} }
PostFileApi(DataFile("notes/"+noteString), w, r) PostFileApi(files.DataFile("notes/"+noteString), w, r)
} }
// GraceActiveApi returns "true" if grace period is active, and "false" otherwise. // GraceActiveApi returns "true" if grace period is active, and "false" otherwise.
func GraceActiveApi(w http.ResponseWriter, r *http.Request) { func GraceActiveApi(w http.ResponseWriter, r *http.Request) {
value := "false" value := "false"
if GraceActive() { if config.Cfg.Grace() {
value = "true" value = "true"
} }
HandleWrite(w.Write([]byte(value))) HandleWrite(w.Write([]byte(value)))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }

View file

@ -1,4 +1,4 @@
package main package server
import ( import (
"crypto/sha256" "crypto/sha256"
@ -9,6 +9,9 @@ import (
"os" "os"
"strings" "strings"
"time" "time"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
) )
type failedLogin struct { type failedLogin struct {
@ -22,7 +25,7 @@ var failedLogins []failedLogin
// NoteLoginFail attempts to log and counteract bruteforce attacks. // NoteLoginFail attempts to log and counteract bruteforce attacks.
func NoteLoginFail(username string, password string, r *http.Request) { func NoteLoginFail(username string, password string, r *http.Request) {
slog.Warn("failed auth", "username", username, "password", password, "address", r.RemoteAddr) slog.Warn("failed auth", "username", username, "password", password, "address", r.RemoteAddr)
NotifyTelegram(fmt.Sprintf(TranslatableText("info.telegram.auth_fail")+":\nusername=%s\npassword=%s\nremote=%s", username, password, r.RemoteAddr)) NotifyTelegram(fmt.Sprintf(lang.Translate("info.telegram.auth_fail")+":\nusername=%s\npassword=%s\nremote=%s", username, password, r.RemoteAddr))
attempt := failedLogin{username, password, time.Now()} attempt := failedLogin{username, password, time.Now()}
updatedLogins := []failedLogin{attempt} updatedLogins := []failedLogin{attempt}
@ -34,7 +37,7 @@ func NoteLoginFail(username string, password string, r *http.Request) {
failedLogins = updatedLogins failedLogins = updatedLogins
// At least 3 failed attempts in last 100 seconds -> likely bruteforce // At least 3 failed attempts in last 100 seconds -> likely bruteforce
if len(failedLogins) >= 3 && Cfg.Scram { if len(failedLogins) >= 3 && config.Cfg.Scram {
Scram() Scram()
} }
} }
@ -49,8 +52,8 @@ func BasicAuth(next http.Handler) http.Handler {
// Calculate SHA-256 hashes for equal length in ConstantTimeCompare // Calculate SHA-256 hashes for equal length in ConstantTimeCompare
usernameHash := sha256.Sum256([]byte(username)) usernameHash := sha256.Sum256([]byte(username))
passwordHash := sha256.Sum256([]byte(password)) passwordHash := sha256.Sum256([]byte(password))
expectedUsernameHash := sha256.Sum256([]byte(Cfg.Username)) expectedUsernameHash := sha256.Sum256([]byte(config.Cfg.Username))
expectedPasswordHash := sha256.Sum256([]byte(Cfg.Password)) expectedPasswordHash := sha256.Sum256([]byte(config.Cfg.Password))
usernameMatch := subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1 usernameMatch := subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1
passwordMatch := subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1 passwordMatch := subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1
@ -72,22 +75,22 @@ func BasicAuth(next http.Handler) http.Handler {
// Scram shuts down the service, useful in case of suspected attack. // Scram shuts down the service, useful in case of suspected attack.
func Scram() { func Scram() {
slog.Warn("SCRAM triggered, shutting down") slog.Warn("SCRAM triggered, shutting down")
NotifyTelegram(TranslatableText("info.telegram.scram")) NotifyTelegram(lang.Translate("info.telegram.scram"))
os.Exit(0) os.Exit(0)
} }
// NotifyTelegram attempts to send a message to admin through Telegram. // NotifyTelegram attempts to send a message to admin through Telegram.
func NotifyTelegram(msg string) { func NotifyTelegram(msg string) {
if Cfg.TelegramChat == "" || Cfg.TelegramToken == "" { if config.Cfg.TelegramChat == "" || config.Cfg.TelegramToken == "" {
slog.Debug("ignoring telegram request due to lack of credentials") slog.Debug("ignoring telegram request due to lack of credentials")
return return
} }
client := &http.Client{} client := &http.Client{}
data := "chat_id=" + Cfg.TelegramChat + "&text=" + msg data := "chat_id=" + config.Cfg.TelegramChat + "&text=" + msg
if Cfg.TelegramTopic != "" { if config.Cfg.TelegramTopic != "" {
data += "&message_thread_id=" + Cfg.TelegramTopic data += "&message_thread_id=" + config.Cfg.TelegramTopic
} }
req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+Cfg.TelegramToken+"/sendMessage", strings.NewReader(data)) req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+config.Cfg.TelegramToken+"/sendMessage", strings.NewReader(data))
if err != nil { if err != nil {
slog.Error("failed telegram request", "error", err) slog.Error("failed telegram request", "error", err)
return return

31
internal/server/config.go Normal file
View file

@ -0,0 +1,31 @@
package server
import (
"log/slog"
"net/http"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
)
// PostConfig calls PostEntry for config file, then reloads the config.
func PostConfig(w http.ResponseWriter, r *http.Request) {
PostEntry(config.ConfigFile, w, r)
err := config.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 := config.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"), http.StatusFound)
return
}
w.WriteHeader(http.StatusOK)
}

65
internal/server/days.go Normal file
View file

@ -0,0 +1,65 @@
package server
import (
"html/template"
"net/http"
"strings"
"time"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
"github.com/go-chi/chi/v5"
)
// GetDays calls GetEntries for previous days' entries.
func GetDays(w http.ResponseWriter, r *http.Request) {
description := template.HTML(
"<a href=\"#footer\">" + template.HTMLEscapeString(lang.Translate("prompt.days")) + "</a>")
GetEntries(w, r, lang.Translate("title.days"), description, "day", func(files []string) []Entry {
var filesFormatted []Entry
for i := range files {
v := files[len(files)-1-i] // This is suboptimal, but reverse order is better here
dayString := v
t, err := time.Parse(time.DateOnly, v)
if err == nil {
dayString = t.Format("02 Jan 2006")
}
// Fancy text for today and tomorrow
// This looks bad, but strings.Title is deprecated, and I'm not importing a golang.org/x package for this...
// (chances we ever run into tomorrow are really low)
if v == config.Cfg.TodayDate() {
dayString = lang.Translate("link.today")
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
} else if v > config.Cfg.TodayDate() {
dayString = lang.Translate("link.tomorrow")
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
}
filesFormatted = append(filesFormatted, Entry{Title: dayString, Link: "day/" + v})
}
return filesFormatted
})
}
// GetDay calls GetEntry for a day entry.
func GetDay(w http.ResponseWriter, r *http.Request) {
dayString := chi.URLParam(r, "day")
if dayString == "" {
w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("day not specified")))
return
}
if dayString == config.Cfg.TodayDate() { // Today can still be edited
http.Redirect(w, r, "/", http.StatusFound)
return
}
title := dayString
t, err := time.Parse(time.DateOnly, dayString)
if err == nil { // This is low priority so silently fail
title = t.Format("02 Jan 2006")
}
GetEntry(w, r, title, files.DataFile("day/"+dayString), false)
}

View file

@ -0,0 +1,80 @@
package server
import (
"errors"
"html/template"
"log/slog"
"net/http"
"os"
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/templates"
)
type EntryList struct {
Title string
Description template.HTML
Entries []Entry
}
type Entry struct {
Title string
Content string
Link string
}
type formatEntries func([]string) []Entry
// GetEntries handles showing a list.
func GetEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) {
filesList, err := files.List(dir)
if err != nil {
slog.Error("error reading file list", "directory", dir, "error", err)
InternalError(w, r)
return
}
var filesFormatted = format(filesList)
err = templates.List.ExecuteTemplate(w, "base", EntryList{Title: title, Description: description, Entries: filesFormatted})
if err != nil {
slog.Error("error executing template", "error", err)
InternalError(w, r)
return
}
}
// 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 := files.Read(filename)
if err != nil {
if editable && errors.Is(err, os.ErrNotExist) {
entry = []byte("")
} else {
slog.Error("error reading entry file", "error", err, "file", filename)
InternalError(w, r)
return
}
}
if editable {
err = templates.Edit.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)})
} else {
err = templates.View.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)})
}
if err != nil {
InternalError(w, r)
return
}
}
// 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 := files.Save(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"), http.StatusFound)
return
}
}

32
internal/server/errors.go Normal file
View file

@ -0,0 +1,32 @@
package server
import (
"log/slog"
"net/http"
"git.a71.su/Andrew71/hibiscus-txt/internal/templates"
)
// NotFound returns a user-friendly 404 error page.
func NotFound(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
err := templates.Template404.Execute(w, nil)
if err != nil {
slog.Error("error rendering error 404 page", "error", err)
InternalError(w, r)
return
}
}
// InternalError returns a user-friendly 500 error page.
func InternalError(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
err := templates.Template500.Execute(w, nil)
if err != nil { // Well this is awkward
slog.Error("error rendering error 500 page", "error", err)
HandleWrite(w.Write([]byte("500. Something went *very* wrong.")))
return
}
}

25
internal/server/info.go Normal file
View file

@ -0,0 +1,25 @@
package server
import (
"log/slog"
"net/http"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
"git.a71.su/Andrew71/hibiscus-txt/internal/templates"
)
// GetInfo renders the info page.
func GetInfo(w http.ResponseWriter, r *http.Request) {
err := templates.Info.ExecuteTemplate(w, "base", config.Info)
if err != nil {
slog.Error("error executing template", "error", err)
InternalError(w, r)
return
}
}
// GetVersionApi returns current app version.
func GetVersionApi(w http.ResponseWriter, r *http.Request) {
HandleWrite(w.Write([]byte(config.Info.Version)))
w.WriteHeader(http.StatusOK)
}

54
internal/server/notes.go Normal file
View file

@ -0,0 +1,54 @@
package server
import (
"html/template"
"net/http"
"net/url"
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
"github.com/go-chi/chi/v5"
)
// 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(\"" + template.HTMLEscapeString(lang.Translate("prompt.notes")) + "\")'>" + template.HTMLEscapeString(lang.Translate("button.notes")) + "</a>" +
" <noscript>(" + template.HTMLEscapeString(lang.Translate("noscript.notes")) + ")</noscript>")
GetEntries(w, r, lang.Translate("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})
}
return filesFormatted
})
}
// GetNote calls GetEntry for a note.
func GetNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("note not specified")))
return
}
// Handle non-latin note names
if decodedNote, err := url.QueryUnescape(noteString); err == nil {
noteString = decodedNote
}
GetEntry(w, r, noteString, files.DataFile("notes/"+noteString), true)
}
// PostNote calls PostEntry for a note.
func PostNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("note not specified")))
return
}
PostEntry(files.DataFile("notes/"+noteString), w, r)
}

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

Before

Width:  |  Height:  |  Size: 894 B

After

Width:  |  Height:  |  Size: 894 B

View file

@ -1,14 +1,24 @@
package main package server
import ( import (
"github.com/go-chi/chi/v5" "embed"
"github.com/go-chi/chi/v5/middleware"
"log" "log"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
) )
// public contains the static files e.g. CSS, JS.
//
//go:embed public
var public embed.FS
// Serve starts the app's web server. // Serve starts the app's web server.
func Serve() { func Serve() {
r := chi.NewRouter() r := chi.NewRouter()
@ -20,18 +30,20 @@ func Serve() {
userRouter := chi.NewRouter() userRouter := chi.NewRouter()
userRouter.Use(BasicAuth) userRouter.Use(BasicAuth)
userRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { userRouter.Get("/", func(w http.ResponseWriter, r *http.Request) {
GetEntry(w, r, TranslatableText("title.today"), DataFile("day/"+TodayDate()), true) GetEntry(w, r, lang.Translate("title.today"), files.DataFile("day/"+config.Cfg.TodayDate()), true)
}) })
userRouter.Post("/", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) }) userRouter.Post("/", func(w http.ResponseWriter, r *http.Request) { PostEntry(files.DataFile("day/"+config.Cfg.TodayDate()), w, r) })
userRouter.Get("/day", GetDays) userRouter.Get("/day", GetDays)
userRouter.Get("/day/{day}", GetDay) userRouter.Get("/day/{day}", GetDay)
userRouter.Get("/notes", GetNotes) userRouter.Get("/notes", GetNotes)
userRouter.Get("/notes/{note}", GetNote) userRouter.Get("/notes/{note}", GetNote)
userRouter.Post("/notes/{note}", PostNote) userRouter.Post("/notes/{note}", PostNote)
userRouter.Get("/info", GetInfo) userRouter.Get("/info", GetInfo)
userRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "readme.txt", DataFile("readme"), true) }) userRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) {
userRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("readme"), w, r) }) GetEntry(w, r, "readme.txt", files.DataFile("readme"), true)
userRouter.Get("/config", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "config.txt", ConfigFile, true) }) })
userRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostEntry(files.DataFile("readme"), w, r) })
userRouter.Get("/config", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "config.txt", config.ConfigFile, true) })
userRouter.Post("/config", PostConfig) userRouter.Post("/config", PostConfig)
r.Mount("/", userRouter) r.Mount("/", userRouter)
@ -45,19 +57,23 @@ func Serve() {
apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { GetFileList("notes", w) }) apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { GetFileList("notes", w) })
apiRouter.Get("/notes/{note}", GetNoteApi) apiRouter.Get("/notes/{note}", GetNoteApi)
apiRouter.Post("/notes/{note}", PostNoteApi) apiRouter.Post("/notes/{note}", PostNoteApi)
apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { GetFileApi(DataFile("day/"+TodayDate()), w) }) apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) {
apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) }) GetFileApi(files.DataFile("day/"+config.Cfg.TodayDate()), w)
apiRouter.Get("/export", GetExport) })
apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) {
PostEntry(files.DataFile("day/"+config.Cfg.TodayDate()), w, r)
})
apiRouter.Get("/export", files.GetExport)
apiRouter.Get("/grace", GraceActiveApi) apiRouter.Get("/grace", GraceActiveApi)
apiRouter.Get("/version", GetVersionApi) apiRouter.Get("/version", GetVersionApi)
apiRouter.Get("/reload", ConfigReloadApi) apiRouter.Get("/reload", ConfigReloadApi)
r.Mount("/api", apiRouter) r.Mount("/api", apiRouter)
// Static files // Static files
fs := http.FileServer(http.FS(Public)) fs := http.FileServer(http.FS(public))
r.Handle("/public/*", fs) r.Handle("/public/*", fs)
slog.Info("🌺 Website working", "port", Cfg.Port) slog.Info("🌺 Website working", "port", config.Cfg.Port)
slog.Debug("Debug mode enabled") slog.Debug("Debug mode enabled")
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(Cfg.Port), r)) log.Fatal(http.ListenAndServe(":"+strconv.Itoa(config.Cfg.Port), r))
} }

View file

@ -0,0 +1,38 @@
package templates
import (
"embed"
"html/template"
"log/slog"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
)
// 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 {
data, err := pages.ReadFile(name)
if err != nil {
slog.Error("error reading embedded file", "err", err)
}
return data
}
var templateFuncs = map[string]interface{}{
"translate": lang.Translate,
"info": func() config.AppInfo { return config.Info },
"config": func() config.Config { return config.Cfg },
}
var Edit = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/edit.html"))
var View = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/entry.html"))
var List = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/list.html"))
var Info = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/info.html"))
var Template404 = template.Must(template.New("404").Funcs(templateFuncs).ParseFS(pages, "pages/error/404.html"))
var Template500 = template.Must(template.New("500").Funcs(templateFuncs).ParseFS(pages, "pages/error/500.html"))

View file

@ -1,13 +1,13 @@
{{ define "header" }} {{ define "header" }}
<header> <header>
<h1>{{ config.Title }}</h1> <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>{{ translate "time.date" }} <span id="today-date">a place</span> <span id="grace" hidden>({{ translate "time.grace" }})</span></p>
</header> </header>
{{ end }} {{ end }}
{{- define "base" -}} {{- define "base" -}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ translatableText "lang" }}"> <html lang="{{ translate "lang" }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -35,7 +35,7 @@
{{ define "footer" }} {{ define "footer" }}
<footer id="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> <p><a href="/">{{ translate "link.today" }}</a> | <a href="/day">{{ translate "link.days" }}</a> | <a href="/notes">{{ translate "link.notes" }}</a>
<span style="float:right;"><a class="no-accent" href="/info" title="{{ translatableText "link.info" }}">v{{ info.Version }}</a></span></p> <span style="float:right;"><a class="no-accent" href="/info" title="{{ translate "link.info" }}">v{{ info.Version }}</a></span></p>
</footer> </footer>
{{ end }} {{ end }}

View file

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

View file

@ -11,8 +11,8 @@
<body> <body>
<main> <main>
<h1>Error 404 - Not Found</h1> <h1>Error 404 - Not Found</h1>
<p>{{ translatableText "error.404" }}</p> <p>{{ translate "error.404" }}</p>
<p><a href="/">{{ translatableText "error.prompt" }}</a></p> <p><a href="/">{{ translate "error.prompt" }}</a></p>
</main> </main>
</body> </body>
</html> </html>

View file

@ -11,8 +11,8 @@
<body> <body>
<main> <main>
<h1>Error 500 - Internal Server Error</h1> <h1>Error 500 - Internal Server Error</h1>
<p>{{ translatableText "error.500" }}</p> <p>{{ translate "error.500" }}</p>
<p><a href="/">{{ translatableText "error.prompt" }}</a></p> <p><a href="/">{{ translate "error.prompt" }}</a></p>
</main> </main>
</body> </body>
</html> </html>

View file

@ -0,0 +1,9 @@
{{ define "main" }}
<h2>{{ translate "title.info" }}</h2>
<ul>
<li>{{ translate "info.version" }} - {{ info.Version }} (<a href="{{ .SourceLink }}">{{ translate "info.version.link" }}</a>)</li>
<li><a href="/config">{{ translate "info.config" }}</a></li>
<li><a href="/readme">{{ translate "info.readme" }}</a></li>
<li><a href="/api/export" download="hibiscus">{{ translate "info.export" }}</a></li>
</ul>
{{ end }}

View file

@ -1,9 +1,7 @@
package main package main
var Cfg = ConfigInit() import "git.a71.su/Andrew71/hibiscus-txt/internal/app"
func main() { func main() {
FlagInit() app.Execute()
LogInit()
Serve()
} }

View file

@ -1,9 +0,0 @@
{{ define "main" }}
<h2>{{ translatableText "title.info" }}</h2>
<ul>
<li>{{ translatableText "info.version" }} - {{ info.Version }} (<a href="{{ .SourceLink }}">{{ translatableText "info.version.link" }}</a>)</li>
<li><a href="/config">{{ translatableText "info.config" }}</a></li>
<li><a href="/readme">{{ translatableText "info.readme" }}</a></li>
<li><a href="/api/export" download="hibiscus">{{ translatableText "info.export" }}</a></li>
</ul>
{{ end }}

234
routes.go
View file

@ -1,234 +0,0 @@
package main
import (
"embed"
"errors"
"html/template"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
type EntryList struct {
Title string
Description template.HTML
Entries []Entry
}
type Entry struct {
Title string
Content string
Link string
}
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 {
data, err := Pages.ReadFile(name)
if err != nil {
slog.Error("error reading embedded file", "err", err)
}
return data
}
var templateFuncs = map[string]interface{}{
"translatableText": TranslatableText,
"info": func() AppInfo { return Info },
"config": func() Config { return Cfg },
}
var editTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/edit.html"))
var viewTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/entry.html"))
var listTemplate = template.Must(template.New("").Funcs(templateFuncs).ParseFS(Pages, "pages/base.html", "pages/list.html"))
var template404 = template.Must(template.New("404").Funcs(templateFuncs).ParseFS(Pages, "pages/error/404.html"))
// NotFound returns a user-friendly 404 error page.
func NotFound(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
err := template404.Execute(w, nil)
if err != nil {
slog.Error("error rendering error 404 page", "error", err)
InternalError(w, r)
return
}
}
var template500 = template.Must(template.New("500").Funcs(templateFuncs).ParseFS(Pages, "pages/error/500.html"))
// InternalError returns a user-friendly 500 error page.
func InternalError(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
err := template500.Execute(w, nil)
if err != nil { // Well this is awkward
slog.Error("error rendering error 500 page", "error", err)
HandleWrite(w.Write([]byte("500. Something went *very* wrong.")))
return
}
}
// 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 {
slog.Error("error reading file list", "directory", dir, "error", err)
InternalError(w, r)
return
}
var filesFormatted = format(filesList)
err = listTemplate.ExecuteTemplate(w, "base", EntryList{Title: title, Description: description, Entries: filesFormatted})
if err != nil {
slog.Error("error executing template", "error", err)
InternalError(w, r)
return
}
}
// GetDays calls GetEntries for previous days' entries.
func GetDays(w http.ResponseWriter, r *http.Request) {
description := template.HTML(
"<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 {
v := files[len(files)-1-i] // This is suboptimal, but reverse order is better here
dayString := v
t, err := time.Parse(time.DateOnly, v)
if err == nil {
dayString = t.Format("02 Jan 2006")
}
// Fancy text for today and tomorrow
// This looks bad, but strings.Title is deprecated, and I'm not importing a golang.org/x package for this...
// (chances we ever run into tomorrow are really low)
if v == TodayDate() {
dayString = TranslatableText("link.today")
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
} else if v > TodayDate() {
dayString = TranslatableText("link.tomorrow")
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
}
filesFormatted = append(filesFormatted, Entry{Title: dayString, Link: "day/" + v})
}
return filesFormatted
})
}
// 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(\"" + 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) // 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.
func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename string, editable bool) {
entry, err := ReadFile(filename)
if err != nil {
if editable && errors.Is(err, os.ErrNotExist) {
entry = []byte("")
} else {
slog.Error("error reading entry file", "error", err, "file", filename)
InternalError(w, r)
return
}
}
if editable {
err = editTemplate.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)})
} else {
err = viewTemplate.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)})
}
if err != nil {
InternalError(w, r)
return
}
}
// 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"), http.StatusFound)
return
}
}
// GetDay calls GetEntry for a day entry.
func GetDay(w http.ResponseWriter, r *http.Request) {
dayString := chi.URLParam(r, "day")
if dayString == "" {
w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("day not specified")))
return
}
if dayString == TodayDate() { // Today can still be edited
http.Redirect(w, r, "/", http.StatusFound)
return
}
title := dayString
t, err := time.Parse(time.DateOnly, dayString)
if err == nil { // This is low priority so silently fail
title = t.Format("02 Jan 2006")
}
GetEntry(w, r, title, DataFile("day/"+dayString), false)
}
// GetNote calls GetEntry for a note.
func GetNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("note not specified")))
return
}
// Handle non-latin note names
if decodedNote, err := url.QueryUnescape(noteString); err == nil {
noteString = decodedNote
}
GetEntry(w, r, noteString, DataFile("notes/"+noteString), true)
}
// PostNote calls PostEntry for a note.
func PostNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("note not specified")))
return
}
PostEntry(DataFile("notes/"+noteString), w, r)
}