Compare commits
No commits in common. "v2" and "master" have entirely different histories.
54 changed files with 694 additions and 859 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -26,5 +26,4 @@ hibiscus-txt
|
||||||
# Testing data
|
# Testing data
|
||||||
data/
|
data/
|
||||||
config/log.txt
|
config/log.txt
|
||||||
config/dev-config.txt
|
config/dev-config.txt
|
||||||
config/style.css
|
|
|
@ -2,15 +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 the project internally
|
|
||||||
* Log *directory* is now specified as opposed to *file*.
|
|
||||||
Files in that directory are generated as `hibiscus_YYYY-MM-DD_HH:MM:SS.log`
|
|
||||||
* Adjusted default theme
|
* Adjusted default theme
|
||||||
* Error pages are now translated
|
* Error pages are now translated
|
||||||
* API: `/api/today` POST now behaves like other file uploads
|
|
||||||
|
|
||||||
## v1.1.4
|
## v1.1.4
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
Simple plaintext diary.
|
Simple plaintext diary.
|
||||||
|
|
||||||
This project is *very* opinionated and minimal, and is designed primarily for my usage.
|
This project is *very* opinionated and minimal, and is designed primarily for my usage.
|
||||||
As a result, neither security nor stability are guaranteed.
|
As a result, I can't guarantee that it's either secure or stable.
|
||||||
|
|
||||||
## Features:
|
## Features:
|
||||||
|
|
||||||
|
|
5
TODO.md
5
TODO.md
|
@ -1,12 +1,9 @@
|
||||||
# TODO
|
# TODO
|
||||||
List of things to add to this project
|
List of things to add to this project
|
||||||
|
|
||||||
## Priority 2.0.0
|
## Urgent (1.1.5-2.0.0)
|
||||||
* (pre-release) re-write README
|
|
||||||
* `style.css` in config instead of theme (provide themes as examples in repo)
|
* `style.css` in config instead of theme (provide themes as examples in repo)
|
||||||
* Auth improvement so it DOESN'T ASK ME FOR PASSWORD EVERY DAY UGH XD
|
* Auth improvement so it DOESN'T ASK ME FOR PASSWORD EVERY DAY UGH XD
|
||||||
* Specify data directory in config or with flags
|
|
||||||
* Configure more of the app with flags, possibly move to `cobra` for this
|
|
||||||
|
|
||||||
## Nice to have
|
## Nice to have
|
||||||
* Forward/backward buttons for days
|
* Forward/backward buttons for days
|
||||||
|
|
108
api.go
Normal file
108
api.go
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleWrite handles error in output of ResponseWriter.Write.
|
||||||
|
func HandleWrite(_ int, err error) {
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error writing response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileApi returns raw contents of a file.
|
||||||
|
func GetFileApi(filename string, w http.ResponseWriter) {
|
||||||
|
fileContents, err := ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
http.Error(w, "file not found", http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "error reading found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
HandleWrite(w.Write(fileContents))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
HandleWrite(w.Write([]byte("error reading body")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = SaveFile(filename, body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
HandleWrite(w.Write([]byte("error saving file")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
HandleWrite(w.Write([]byte("wrote to file")))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
http.Error(w, "error searching for files", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filenamesJson, err := json.Marshal(filenames)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "error marshaling json", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
HandleWrite(w.Write(filenamesJson))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
HandleWrite(w.Write([]byte("day not specified")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
GetFileApi(DataFile("day/"+dayString), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNoteApi returns contents of a note specified in URL.
|
||||||
|
func GetNoteApi(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
|
||||||
|
}
|
||||||
|
GetFileApi(DataFile("notes/"+noteString), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
HandleWrite(w.Write([]byte("note not specified")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
PostFileApi(DataFile("notes/"+noteString), w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GraceActiveApi returns "true" if grace period is active, and "false" otherwise.
|
||||||
|
func GraceActiveApi(w http.ResponseWriter, r *http.Request) {
|
||||||
|
value := "false"
|
||||||
|
if GraceActive() {
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
HandleWrite(w.Write([]byte(value)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
105
auth.go
Normal file
105
auth.go
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type failedLogin struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var failedLogins []failedLogin
|
||||||
|
|
||||||
|
// 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.auth_fail")+":\nusername=%s\npassword=%s\nremote=%s", username, password, r.RemoteAddr))
|
||||||
|
|
||||||
|
attempt := failedLogin{username, password, time.Now()}
|
||||||
|
updatedLogins := []failedLogin{attempt}
|
||||||
|
for _, attempt := range failedLogins {
|
||||||
|
if 100 > time.Since(attempt.Timestamp).Seconds() {
|
||||||
|
updatedLogins = append(updatedLogins, attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failedLogins = updatedLogins
|
||||||
|
|
||||||
|
// At least 3 failed attempts in last 100 seconds -> likely bruteforce
|
||||||
|
if len(failedLogins) >= 3 && Cfg.Scram {
|
||||||
|
Scram()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
func BasicAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if ok {
|
||||||
|
// Calculate SHA-256 hashes for equal length in ConstantTimeCompare
|
||||||
|
usernameHash := sha256.Sum256([]byte(username))
|
||||||
|
passwordHash := sha256.Sum256([]byte(password))
|
||||||
|
expectedUsernameHash := sha256.Sum256([]byte(Cfg.Username))
|
||||||
|
expectedPasswordHash := sha256.Sum256([]byte(Cfg.Password))
|
||||||
|
|
||||||
|
usernameMatch := subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1
|
||||||
|
passwordMatch := subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1
|
||||||
|
|
||||||
|
if usernameMatch && passwordMatch {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
NoteLoginFail(username, password, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthorized, inform client that we have auth and return 401
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scram shuts down the service, useful in case of suspected attack.
|
||||||
|
func Scram() {
|
||||||
|
slog.Warn("SCRAM triggered, shutting down")
|
||||||
|
NotifyTelegram(TranslatableText("info.telegram.scram"))
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyTelegram 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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := &http.Client{}
|
||||||
|
data := "chat_id=" + Cfg.TelegramChat + "&text=" + msg
|
||||||
|
if Cfg.TelegramTopic != "" {
|
||||||
|
data += "&message_thread_id=" + Cfg.TelegramTopic
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+Cfg.TelegramToken+"/sendMessage", strings.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed telegram request", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed telegram request", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
slog.Error("failed telegram request", "status", resp.Status)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package config
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
@ -6,17 +6,14 @@ 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 {
|
||||||
|
@ -29,7 +26,7 @@ type Config struct {
|
||||||
Theme string `config:"theme" type:"string"`
|
Theme string `config:"theme" type:"string"`
|
||||||
Title string `config:"title" type:"string"`
|
Title string `config:"title" type:"string"`
|
||||||
LogToFile bool `config:"log_to_file" type:"bool"`
|
LogToFile bool `config:"log_to_file" type:"bool"`
|
||||||
LogDir string `config:"log_dir" type:"string"`
|
LogFile string `config:"log_file" type:"string"`
|
||||||
Scram bool `config:"enable_scram" type:"bool"`
|
Scram bool `config:"enable_scram" type:"bool"`
|
||||||
|
|
||||||
TelegramToken string `config:"tg_token" type:"string"`
|
TelegramToken string `config:"tg_token" type:"string"`
|
||||||
|
@ -37,7 +34,7 @@ type Config struct {
|
||||||
TelegramTopic string `config:"tg_topic" type:"string"`
|
TelegramTopic string `config:"tg_topic" type:"string"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultConfig = Config{
|
var DefaultConfig = Config{
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
Password: "admin",
|
Password: "admin",
|
||||||
Port: 7101,
|
Port: 7101,
|
||||||
|
@ -47,7 +44,7 @@ var defaultConfig = Config{
|
||||||
Theme: "",
|
Theme: "",
|
||||||
Title: "🌺 Hibiscus.txt",
|
Title: "🌺 Hibiscus.txt",
|
||||||
LogToFile: false,
|
LogToFile: false,
|
||||||
LogDir: "logs",
|
LogFile: "config/log.txt",
|
||||||
Scram: false,
|
Scram: false,
|
||||||
|
|
||||||
TelegramToken: "",
|
TelegramToken: "",
|
||||||
|
@ -59,7 +56,7 @@ var defaultConfig = Config{
|
||||||
func (c *Config) String() string {
|
func (c *Config) String() string {
|
||||||
output := ""
|
output := ""
|
||||||
v := reflect.ValueOf(*c)
|
v := reflect.ValueOf(*c)
|
||||||
vDefault := reflect.ValueOf(defaultConfig)
|
vDefault := reflect.ValueOf(DefaultConfig)
|
||||||
typeOfS := v.Type()
|
typeOfS := v.Type()
|
||||||
for i := 0; i < v.NumField(); i++ {
|
for i := 0; i < v.NumField(); i++ {
|
||||||
key := typeOfS.Field(i).Tag.Get("config")
|
key := typeOfS.Field(i).Tag.Get("config")
|
||||||
|
@ -75,7 +72,7 @@ func (c *Config) String() string {
|
||||||
// Reload resets, then loads config from the ConfigFile.
|
// Reload resets, then loads config from the ConfigFile.
|
||||||
// It creates the file with mandatory options if it is missing.
|
// It creates the file with mandatory options if it is missing.
|
||||||
func (c *Config) Reload() error {
|
func (c *Config) Reload() error {
|
||||||
*c = defaultConfig // Reset config
|
*c = DefaultConfig // Reset config
|
||||||
|
|
||||||
if _, err := os.Stat(ConfigFile); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(ConfigFile); errors.Is(err, os.ErrNotExist) {
|
||||||
err := c.Save()
|
err := c.Save()
|
||||||
|
@ -146,17 +143,40 @@ func (c *Config) Reload() error {
|
||||||
}
|
}
|
||||||
slog.Debug("reloaded config", "config", c)
|
slog.Debug("reloaded config", "config", c)
|
||||||
|
|
||||||
return lang.SetLanguage(c.Language) // Load selected language
|
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) {
|
func (c *Config) Read() ([]byte, error) {
|
||||||
return files.Read(ConfigFile)
|
return ReadFile(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 files.Save(ConfigFile, []byte(c.String()))
|
return SaveFile(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.
|
|
@ -1,4 +1,4 @@
|
||||||
package files
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
@ -9,7 +9,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ExportPath = "data/export.zip" // TODO: Move to config
|
var ExportPath = "data/export.zip"
|
||||||
|
|
||||||
// Export saves a .zip archive of the data folder to a file.
|
// Export saves a .zip archive of the data folder to a file.
|
||||||
func Export(filename string) error {
|
func Export(filename string) error {
|
|
@ -1,4 +1,4 @@
|
||||||
package files
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
@ -8,6 +8,7 @@ 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.
|
||||||
|
@ -15,8 +16,8 @@ func DataFile(filename string) string {
|
||||||
return "data/" + path.Clean(filename) + ".txt"
|
return "data/" + path.Clean(filename) + ".txt"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read returns contents of a file.
|
// ReadFile returns contents of a file.
|
||||||
func Read(filename string) ([]byte, error) {
|
func ReadFile(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
|
||||||
}
|
}
|
||||||
|
@ -28,8 +29,8 @@ func Read(filename string) ([]byte, error) {
|
||||||
return fileContents, nil
|
return fileContents, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save Writes contents to a file.
|
// SaveFile Writes contents to a file.
|
||||||
func Save(filename string, contents []byte) error {
|
func SaveFile(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)
|
||||||
|
@ -53,9 +54,9 @@ func Save(filename string, contents []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 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?
|
// NOTE: What if I ever want to list non-text files or those outside data directory?
|
||||||
func List(directory string) ([]string, error) {
|
func ListFiles(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
|
||||||
|
@ -66,3 +67,25 @@ func List(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
|
||||||
|
}
|
|
@ -1,37 +1,34 @@
|
||||||
package app
|
package main
|
||||||
|
|
||||||
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() {
|
||||||
conf := flag.String("config", "", "override config file")
|
config := 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 *conf != "" {
|
if *config != "" {
|
||||||
config.ConfigFile = *conf
|
ConfigFile = *config
|
||||||
err := config.Cfg.Reload()
|
err := Cfg.Reload()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if *username != "" {
|
if *username != "" {
|
||||||
config.Cfg.Username = *username
|
Cfg.Username = *username
|
||||||
}
|
}
|
||||||
if *password != "" {
|
if *password != "" {
|
||||||
config.Cfg.Password = *password
|
Cfg.Password = *password
|
||||||
}
|
}
|
||||||
if *port != 0 {
|
if *port != 0 {
|
||||||
config.Cfg.Port = *port
|
Cfg.Port = *port
|
||||||
}
|
}
|
||||||
logging.DebugMode = *debug
|
DebugMode = *debug
|
||||||
}
|
}
|
4
go.mod
4
go.mod
|
@ -1,5 +1,5 @@
|
||||||
module git.a71.su/Andrew71/hibiscus-txt
|
module hibiscus-txt
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require github.com/go-chi/chi/v5 v5.1.0
|
require github.com/go-chi/chi/v5 v5.0.12
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -1,4 +1,2 @@
|
||||||
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=
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package lang
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
@ -6,24 +6,24 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed lang
|
//go:embed i18n
|
||||||
var lang embed.FS
|
var I18n 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 := "lang/" + language + ".json"
|
filename := "i18n/" + language + ".json"
|
||||||
fileContents, err := lang.ReadFile(filename)
|
fileContents, err := I18n.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate attempts to match an id to a string in current language.
|
// TranslatableText attempts to match an id to a string in current language.
|
||||||
func Translate(id string) string {
|
func TranslatableText(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
|
36
info.go
Normal file
36
info.go
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
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)
|
||||||
|
}
|
|
@ -1,12 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
type AppInfo struct {
|
|
||||||
version string
|
|
||||||
source string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Info contains app information.
|
|
||||||
var Info = AppInfo{
|
|
||||||
version: "2.0.0",
|
|
||||||
source: "https://git.a71.su/Andrew71/hibiscus",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version returns the current app version
|
|
||||||
func (i AppInfo) Version() string {
|
|
||||||
return i.version
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source returns app's git repository
|
|
||||||
func (i AppInfo) Source() string {
|
|
||||||
return i.source
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
package logging
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
var DebugMode = false
|
|
||||||
|
|
||||||
// file returns the appropriate filename for log
|
|
||||||
// (log_dir/hibiscus_YYYY-MM-DD_HH:MM:SS.log)
|
|
||||||
func file() string {
|
|
||||||
return config.Cfg.LogDir + "/hibiscus_" + time.Now().In(config.Cfg.Timezone).Format("2006-01-02_15:04:05") + ".log"
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogInit makes slog output to both os.Stdout and a file if needed, and sets slog.LevelDebug if enabled.
|
|
||||||
func LogInit() {
|
|
||||||
logFile := file()
|
|
||||||
var w io.Writer = os.Stdout
|
|
||||||
if config.Cfg.LogToFile {
|
|
||||||
// Create dir in case it doesn't exist yet to avoid errors
|
|
||||||
err := os.MkdirAll(path.Dir(logFile), 0755)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("error creating log dir, logging to stdout only", "path", path.Dir(logFile), "error", err)
|
|
||||||
} else {
|
|
||||||
f, err := os.OpenFile(logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("error opening log file, logging to stdout only", "path", logFile, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// No defer f.Close() because that breaks the MultiWriter
|
|
||||||
w = io.MultiWriter(f, os.Stdout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make slog and chi use intended format
|
|
||||||
var opts *slog.HandlerOptions
|
|
||||||
if DebugMode {
|
|
||||||
opts = &slog.HandlerOptions{Level: slog.LevelDebug}
|
|
||||||
}
|
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(w, opts)))
|
|
||||||
middleware.DefaultLogger = middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: log.Default(), NoColor: true})
|
|
||||||
slog.Debug("Debug mode enabled") // This string is only shown if debugging
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/server/auth"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ApiRouter *chi.Mux
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
ApiRouter = chi.NewRouter()
|
|
||||||
ApiRouter.Use(auth.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("/day", func(w http.ResponseWriter, r *http.Request) { fileList("day", w) })
|
|
||||||
ApiRouter.Get("/day/{day}", getDay)
|
|
||||||
ApiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { fileList("notes", w) })
|
|
||||||
ApiRouter.Get("/notes/{note}", getNote)
|
|
||||||
ApiRouter.Post("/notes/{note}", postNote)
|
|
||||||
ApiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
getFile(files.DataFile("day/"+config.Cfg.TodayDate()), w)
|
|
||||||
})
|
|
||||||
ApiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
postFile(files.DataFile("day/"+config.Cfg.TodayDate()), w, r)
|
|
||||||
})
|
|
||||||
ApiRouter.Get("/export", files.GetExport)
|
|
||||||
ApiRouter.Get("/grace", graceStatus)
|
|
||||||
ApiRouter.Get("/version", getVersion)
|
|
||||||
ApiRouter.Get("/reload", configReload)
|
|
||||||
}
|
|
|
@ -1,124 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/server/util"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// getFile returns raw contents of a file.
|
|
||||||
func getFile(filename string, w http.ResponseWriter) {
|
|
||||||
fileContents, err := files.Read(filename)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
http.Error(w, "file not found", http.StatusNotFound)
|
|
||||||
} else {
|
|
||||||
http.Error(w, "error reading found", http.StatusNotFound)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
util.HandleWrite(w.Write(fileContents))
|
|
||||||
}
|
|
||||||
|
|
||||||
// postFile writes contents of Request.Body to a file.
|
|
||||||
func postFile(filename string, w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
util.HandleWrite(w.Write([]byte("error reading body")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = files.Save(filename, body)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
util.HandleWrite(w.Write([]byte("error saving file")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
util.HandleWrite(w.Write([]byte("wrote to file")))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileList returns JSON list of filenames in a directory without extensions or path.
|
|
||||||
func fileList(directory string, w http.ResponseWriter) {
|
|
||||||
filenames, err := files.List(directory)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "error searching for files", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filenamesJson, err := json.Marshal(filenames)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "error marshaling json", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
util.HandleWrite(w.Write(filenamesJson))
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDay returns raw contents of a daily file specified in URL.
|
|
||||||
func getDay(w http.ResponseWriter, r *http.Request) {
|
|
||||||
dayString := chi.URLParam(r, "day")
|
|
||||||
if dayString == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
util.HandleWrite(w.Write([]byte("day not specified")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
getFile(files.DataFile("day/"+dayString), w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getNote returns contents of a note specified in URL.
|
|
||||||
func getNote(w http.ResponseWriter, r *http.Request) {
|
|
||||||
noteString := chi.URLParam(r, "note")
|
|
||||||
if noteString == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
util.HandleWrite(w.Write([]byte("note not specified")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
getFile(files.DataFile("notes/"+noteString), w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// postNote writes contents of Request.Body to a note specified in URL.
|
|
||||||
func postNote(w http.ResponseWriter, r *http.Request) {
|
|
||||||
noteString := chi.URLParam(r, "note")
|
|
||||||
if noteString == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
util.HandleWrite(w.Write([]byte("note not specified")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
postFile(files.DataFile("notes/"+noteString), w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// graceStatus returns "true" if grace period is active, and "false" otherwise.
|
|
||||||
func graceStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
value := "false"
|
|
||||||
if config.Cfg.Grace() {
|
|
||||||
value = "true"
|
|
||||||
}
|
|
||||||
util.HandleWrite(w.Write([]byte(value)))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getVersion returns current app version.
|
|
||||||
func getVersion(w http.ResponseWriter, r *http.Request) {
|
|
||||||
util.HandleWrite(w.Write([]byte(config.Info.Version())))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// configReload reloads the config. It then redirects back if Referer field is present.
|
|
||||||
func configReload(w http.ResponseWriter, r *http.Request) {
|
|
||||||
err := config.Cfg.Reload()
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
util.HandleWrite(w.Write([]byte(err.Error())))
|
|
||||||
}
|
|
||||||
if r.Referer() != "" {
|
|
||||||
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BasicAuth middleware 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).
|
|
||||||
func BasicAuth(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
username, password, ok := r.BasicAuth()
|
|
||||||
if ok {
|
|
||||||
// Calculate SHA-256 hashes for equal length in ConstantTimeCompare
|
|
||||||
usernameHash := sha256.Sum256([]byte(username))
|
|
||||||
passwordHash := sha256.Sum256([]byte(password))
|
|
||||||
expectedUsernameHash := sha256.Sum256([]byte(config.Cfg.Username))
|
|
||||||
expectedPasswordHash := sha256.Sum256([]byte(config.Cfg.Password))
|
|
||||||
|
|
||||||
usernameMatch := subtle.ConstantTimeCompare(usernameHash[:], expectedUsernameHash[:]) == 1
|
|
||||||
passwordMatch := subtle.ConstantTimeCompare(passwordHash[:], expectedPasswordHash[:]) == 1
|
|
||||||
|
|
||||||
if usernameMatch && passwordMatch {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
noteLoginFail(username, password, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unauthorized, inform client that we have auth and return 401
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,77 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
type failedLogin struct {
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
Timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var failedLogins []failedLogin
|
|
||||||
|
|
||||||
// noteLoginFail attempts to log and counteract brute-force attacks.
|
|
||||||
func noteLoginFail(username string, password string, r *http.Request) {
|
|
||||||
slog.Warn("failed auth", "username", username, "password", password, "address", 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()}
|
|
||||||
updatedLogins := []failedLogin{attempt}
|
|
||||||
for _, attempt := range failedLogins {
|
|
||||||
if 100 > time.Since(attempt.Timestamp).Seconds() {
|
|
||||||
updatedLogins = append(updatedLogins, attempt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
failedLogins = updatedLogins
|
|
||||||
|
|
||||||
// At least 3 failed attempts in last 100 seconds -> likely brute-force
|
|
||||||
if len(failedLogins) >= 3 && config.Cfg.Scram {
|
|
||||||
scram()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// notifyTelegram attempts to send a message to the user through Telegram.
|
|
||||||
func notifyTelegram(msg string) {
|
|
||||||
if config.Cfg.TelegramChat == "" || config.Cfg.TelegramToken == "" {
|
|
||||||
slog.Debug("ignoring telegram request due to lack of credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client := &http.Client{}
|
|
||||||
data := "chat_id=" + config.Cfg.TelegramChat + "&text=" + msg
|
|
||||||
if config.Cfg.TelegramTopic != "" {
|
|
||||||
data += "&message_thread_id=" + config.Cfg.TelegramTopic
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+config.Cfg.TelegramToken+"/sendMessage", strings.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed telegram request", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed telegram request", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
slog.Error("failed telegram request", "status", resp.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// scram shuts down the service, useful in case of suspected attack.
|
|
||||||
func scram() {
|
|
||||||
slog.Warn("SCRAM triggered, shutting down")
|
|
||||||
notifyTelegram(lang.Translate("info.telegram.scram"))
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"log"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/server/api"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/server/routes"
|
|
||||||
"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.
|
|
||||||
func Serve() {
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Use(middleware.RealIP)
|
|
||||||
r.Use(middleware.Logger, middleware.CleanPath, middleware.StripSlashes)
|
|
||||||
r.NotFound(routes.NotFound)
|
|
||||||
|
|
||||||
r.Mount("/", routes.UserRouter) // User-facing routes
|
|
||||||
r.Mount("/api", api.ApiRouter) // API routes
|
|
||||||
|
|
||||||
// Static files
|
|
||||||
fs := http.FileServer(http.FS(public))
|
|
||||||
r.Handle("/public/*", fs)
|
|
||||||
|
|
||||||
slog.Info("🌺 Website working", "port", config.Cfg.Port)
|
|
||||||
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(config.Cfg.Port), r))
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
package routes
|
|
||||||
|
|
||||||
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"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/server/util"
|
|
||||||
"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)
|
|
||||||
util.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)
|
|
||||||
}
|
|
|
@ -1,80 +0,0 @@
|
||||||
package routes
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
package routes
|
|
||||||
|
|
||||||
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)
|
|
||||||
http.Error(w, "500. Something went *very* wrong.", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/server/auth"
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
var UserRouter *chi.Mux
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
UserRouter = chi.NewRouter()
|
|
||||||
UserRouter.Use(auth.BasicAuth)
|
|
||||||
UserRouter.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
getEntry(w, r, lang.Translate("title.today"), files.DataFile("day/"+config.Cfg.TodayDate()), true)
|
|
||||||
})
|
|
||||||
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/{day}", getDay)
|
|
||||||
UserRouter.Get("/notes", getNotes)
|
|
||||||
UserRouter.Get("/notes/{note}", getNote)
|
|
||||||
UserRouter.Post("/notes/{note}", postNote)
|
|
||||||
UserRouter.Get("/info", getInfo)
|
|
||||||
UserRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
getEntry(w, r, "readme.txt", files.DataFile("readme"), 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)
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
|
|
||||||
"git.a71.su/Andrew71/hibiscus-txt/internal/server/util"
|
|
||||||
"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)
|
|
||||||
util.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)
|
|
||||||
util.HandleWrite(w.Write([]byte("note not specified")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
postEntry(files.DataFile("notes/"+noteString), w, r)
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
package util
|
|
||||||
|
|
||||||
import "log/slog"
|
|
||||||
|
|
||||||
// HandleWrite "handles" error in output of ResponseWriter.Write.
|
|
||||||
// Much useful very wow.
|
|
||||||
func HandleWrite(_ int, err error) {
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("error writing response", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
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 []byte("")
|
|
||||||
}
|
|
||||||
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"))
|
|
|
@ -1,9 +0,0 @@
|
||||||
{{ define "main" }}
|
|
||||||
<h2>{{ translate "title.info" }}</h2>
|
|
||||||
<ul>
|
|
||||||
<li>{{ translate "info.version" }} - {{ info.Version }} (<a href="{{ .Source }}">{{ 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 }}
|
|
35
logger.go
Normal file
35
logger.go
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DebugMode = false
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
f, err := os.OpenFile(Cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error opening log file, logging to stdout", "path", Cfg.LogFile, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// No defer f.Close() because that breaks the MultiWriter
|
||||||
|
w = io.MultiWriter(f, os.Stdout)
|
||||||
|
} else {
|
||||||
|
w = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make slog and chi use intended format
|
||||||
|
var opts *slog.HandlerOptions
|
||||||
|
if DebugMode {
|
||||||
|
opts = &slog.HandlerOptions{Level: slog.LevelDebug}
|
||||||
|
}
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(w, opts)))
|
||||||
|
middleware.DefaultLogger = middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: log.Default(), NoColor: true})
|
||||||
|
}
|
6
main.go
6
main.go
|
@ -1,7 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "git.a71.su/Andrew71/hibiscus-txt/internal/app"
|
var Cfg = ConfigInit()
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app.Execute()
|
FlagInit()
|
||||||
|
LogInit()
|
||||||
|
Serve()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
{{ define "header" }}
|
{{ define "header" }}
|
||||||
<header>
|
<header>
|
||||||
<h1>{{ config.Title }}</h1>
|
<h1>{{ config.Title }}</h1>
|
||||||
<p>{{ translate "time.date" }} <span id="today-date">a place</span> <span id="grace" hidden>({{ translate "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>
|
</header>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{- define "base" -}}
|
{{- define "base" -}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ translate "lang" }}">
|
<html lang="{{ translatableText "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="/">{{ translate "link.today" }}</a> | <a href="/day">{{ translate "link.days" }}</a> | <a href="/notes">{{ translate "link.notes" }}</a>
|
<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="{{ translate "link.info" }}">v{{ info.Version }}</a></span></p>
|
<span style="float:right;"><a class="no-accent" href="/info" title="{{ translatableText "link.info" }}">v{{ info.Version }}</a></span></p>
|
||||||
</footer>
|
</footer>
|
||||||
{{ end }}
|
{{ end }}
|
|
@ -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">{{ translate "button.save" }}</button>
|
<button type="submit">{{ translatableText "button.save" }}</button>
|
||||||
</form>
|
</form>
|
||||||
{{ end }}
|
{{ end }}
|
|
@ -11,8 +11,8 @@
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Error 404 - Not Found</h1>
|
<h1>Error 404 - Not Found</h1>
|
||||||
<p>{{ translate "error.404" }}</p>
|
<p>{{ translatableText "error.404" }}</p>
|
||||||
<p><a href="/">{{ translate "error.prompt" }}</a></p>
|
<p><a href="/">{{ translatableText "error.prompt" }}</a></p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
|
@ -11,8 +11,8 @@
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Error 500 - Internal Server Error</h1>
|
<h1>Error 500 - Internal Server Error</h1>
|
||||||
<p>{{ translate "error.500" }}</p>
|
<p>{{ translatableText "error.500" }}</p>
|
||||||
<p><a href="/">{{ translate "error.prompt" }}</a></p>
|
<p><a href="/">{{ translatableText "error.prompt" }}</a></p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
9
pages/info.html
Normal file
9
pages/info.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{{ 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 }}
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 894 B After Width: | Height: | Size: 894 B |
234
routes.go
Normal file
234
routes.go
Normal file
|
@ -0,0 +1,234 @@
|
||||||
|
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)
|
||||||
|
}
|
63
serve.go
Normal file
63
serve.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Serve starts the app's web server.
|
||||||
|
func Serve() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Logger, middleware.CleanPath, middleware.StripSlashes)
|
||||||
|
r.NotFound(NotFound)
|
||||||
|
|
||||||
|
// Routes ==========
|
||||||
|
userRouter := chi.NewRouter()
|
||||||
|
userRouter.Use(BasicAuth)
|
||||||
|
userRouter.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
GetEntry(w, r, TranslatableText("title.today"), DataFile("day/"+TodayDate()), true)
|
||||||
|
})
|
||||||
|
userRouter.Post("/", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) })
|
||||||
|
userRouter.Get("/day", GetDays)
|
||||||
|
userRouter.Get("/day/{day}", GetDay)
|
||||||
|
userRouter.Get("/notes", GetNotes)
|
||||||
|
userRouter.Get("/notes/{note}", GetNote)
|
||||||
|
userRouter.Post("/notes/{note}", PostNote)
|
||||||
|
userRouter.Get("/info", GetInfo)
|
||||||
|
userRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "readme.txt", DataFile("readme"), true) })
|
||||||
|
userRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("readme"), w, r) })
|
||||||
|
userRouter.Get("/config", func(w http.ResponseWriter, r *http.Request) { GetEntry(w, r, "config.txt", ConfigFile, true) })
|
||||||
|
userRouter.Post("/config", PostConfig)
|
||||||
|
r.Mount("/", userRouter)
|
||||||
|
|
||||||
|
// API =============
|
||||||
|
apiRouter := chi.NewRouter()
|
||||||
|
apiRouter.Use(BasicAuth)
|
||||||
|
apiRouter.Get("/readme", func(w http.ResponseWriter, r *http.Request) { GetFileApi("readme", w) })
|
||||||
|
apiRouter.Post("/readme", func(w http.ResponseWriter, r *http.Request) { PostFileApi("readme", w, r) })
|
||||||
|
apiRouter.Get("/day", func(w http.ResponseWriter, r *http.Request) { GetFileList("day", w) })
|
||||||
|
apiRouter.Get("/day/{day}", GetDayApi)
|
||||||
|
apiRouter.Get("/notes", func(w http.ResponseWriter, r *http.Request) { GetFileList("notes", w) })
|
||||||
|
apiRouter.Get("/notes/{note}", GetNoteApi)
|
||||||
|
apiRouter.Post("/notes/{note}", PostNoteApi)
|
||||||
|
apiRouter.Get("/today", func(w http.ResponseWriter, r *http.Request) { GetFileApi(DataFile("day/"+TodayDate()), w) })
|
||||||
|
apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) { PostEntry(DataFile("day/"+TodayDate()), w, r) })
|
||||||
|
apiRouter.Get("/export", GetExport)
|
||||||
|
apiRouter.Get("/grace", GraceActiveApi)
|
||||||
|
apiRouter.Get("/version", GetVersionApi)
|
||||||
|
apiRouter.Get("/reload", ConfigReloadApi)
|
||||||
|
r.Mount("/api", apiRouter)
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
fs := http.FileServer(http.FS(Public))
|
||||||
|
r.Handle("/public/*", fs)
|
||||||
|
|
||||||
|
slog.Info("🌺 Website working", "port", Cfg.Port)
|
||||||
|
slog.Debug("Debug mode enabled")
|
||||||
|
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(Cfg.Port), r))
|
||||||
|
}
|
Loading…
Reference in a new issue