Compare commits
3 commits
Author | SHA1 | Date | |
---|---|---|---|
8ae76cc8e8 | |||
2b0f9c139a | |||
9cab989b78 |
23 changed files with 187 additions and 235 deletions
26
CHANGELOG.md
26
CHANGELOG.md
|
@ -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
|
||||
|
|
22
README.md
22
README.md
|
@ -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
|
||||
|
|
1
TODO.md
1
TODO.md
|
@ -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
56
api.go
|
@ -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
16
auth.go
|
@ -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")
|
||||
|
|
42
config.go
42
config.go
|
@ -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()
|
||||
|
|
|
@ -2,4 +2,4 @@ username=admin
|
|||
password=admin
|
||||
port=7101
|
||||
timezone=Local
|
||||
language=en
|
||||
language=en
|
|
@ -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 {
|
||||
|
|
38
files.go
38
files.go
|
@ -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)
|
||||
}
|
||||
|
|
2
flags.go
2
flags.go
|
@ -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")
|
||||
|
|
7
i18n.go
7
i18n.go
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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
12
info.go
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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}}
|
|
@ -1 +0,0 @@
|
|||
/* Default theme is defined in main.css */
|
112
routes.go
112
routes.go
|
@ -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)
|
||||
}
|
||||
|
|
22
serve.go
22
serve.go
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue