Refactor server routes

This commit is contained in:
Andrew-71 2024-10-23 14:11:02 +03:00
parent ca9b6e05b7
commit 5cbb20dcc4
21 changed files with 429 additions and 390 deletions

View file

@ -10,6 +10,7 @@ These changes are not yet released
Files in that directory are generated as `hibiscus_YYYY-MM-DD_HH:MM:SS.log` 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

View file

@ -37,7 +37,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,
@ -59,7 +59,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 +75,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()

View file

@ -14,15 +14,15 @@ import (
var DebugMode = false var DebugMode = false
// File returns the appropriate filename for log // file returns the appropriate filename for log
// (log_dir/hibiscus_YYYY-MM-DD_HH:MM:SS.log) // (log_dir/hibiscus_YYYY-MM-DD_HH:MM:SS.log)
func File() string { func file() string {
return config.Cfg.LogDir + "/hibiscus_" + time.Now().In(config.Cfg.Timezone).Format("2006-01-02_15:04:05") + ".log" 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. // LogInit makes slog output to both os.Stdout and a file if needed, and sets slog.LevelDebug if enabled.
func LogInit() { func LogInit() {
logFile := File() logFile := file()
var w io.Writer = os.Stdout var w io.Writer = os.Stdout
if config.Cfg.LogToFile { if config.Cfg.LogToFile {
// Create dir in case it doesn't exist yet to avoid errors // Create dir in case it doesn't exist yet to avoid errors

View file

@ -1,112 +0,0 @@
package server
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"os"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
"github.com/go-chi/chi/v5"
)
// HandleWrite handles error in output of ResponseWriter.Write.
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 := 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
}
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 = files.Save(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 := 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
}
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(files.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(files.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(files.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 config.Cfg.Grace() {
value = "true"
}
HandleWrite(w.Write([]byte(value)))
w.WriteHeader(http.StatusOK)
}

View file

@ -0,0 +1,34 @@
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)
}

View file

@ -0,0 +1,124 @@
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)
}

View file

@ -1,108 +0,0 @@
package server
import (
"crypto/sha256"
"crypto/subtle"
"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 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(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 bruteforce
if len(failedLogins) >= 3 && config.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(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)
})
}
// 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)
}
// NotifyTelegram attempts to send a message to admin 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)
}
}

View file

@ -0,0 +1,39 @@
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)
})
}

View file

@ -0,0 +1,77 @@
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)
}

View file

@ -1,31 +0,0 @@
package server
import (
"log/slog"
"net/http"
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
)
// PostConfig calls PostEntry for config file, then reloads the config.
func PostConfig(w http.ResponseWriter, r *http.Request) {
PostEntry(config.ConfigFile, w, r)
err := config.Cfg.Reload()
if err != nil {
slog.Error("error reloading config", "error", err)
}
}
// ConfigReloadApi reloads the config. It then redirects back if Referer field is present.
func ConfigReloadApi(w http.ResponseWriter, r *http.Request) {
err := config.Cfg.Reload()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
HandleWrite(w.Write([]byte(err.Error())))
}
if r.Referer() != "" {
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusFound)
return
}
w.WriteHeader(http.StatusOK)
}

View file

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

38
internal/server/main.go Normal file
View file

@ -0,0 +1,38 @@
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))
}

View file

@ -1,4 +1,4 @@
package server package routes
import ( import (
"html/template" "html/template"
@ -9,14 +9,15 @@ import (
"git.a71.su/Andrew71/hibiscus-txt/internal/config" "git.a71.su/Andrew71/hibiscus-txt/internal/config"
"git.a71.su/Andrew71/hibiscus-txt/internal/files" "git.a71.su/Andrew71/hibiscus-txt/internal/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang" "git.a71.su/Andrew71/hibiscus-txt/internal/lang"
"git.a71.su/Andrew71/hibiscus-txt/internal/server/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// GetDays calls GetEntries for previous days' entries. // getDays calls getEntries for previous days' entries.
func GetDays(w http.ResponseWriter, r *http.Request) { func getDays(w http.ResponseWriter, r *http.Request) {
description := template.HTML( description := template.HTML(
"<a href=\"#footer\">" + template.HTMLEscapeString(lang.Translate("prompt.days")) + "</a>") "<a href=\"#footer\">" + template.HTMLEscapeString(lang.Translate("prompt.days")) + "</a>")
GetEntries(w, r, lang.Translate("title.days"), description, "day", func(files []string) []Entry { getEntries(w, r, lang.Translate("title.days"), description, "day", func(files []string) []Entry {
var filesFormatted []Entry var filesFormatted []Entry
for i := range files { for i := range files {
v := files[len(files)-1-i] // This is suboptimal, but reverse order is better here v := files[len(files)-1-i] // This is suboptimal, but reverse order is better here
@ -42,12 +43,12 @@ func GetDays(w http.ResponseWriter, r *http.Request) {
}) })
} }
// GetDay calls GetEntry for a day entry. // getDay calls getEntry for a day entry.
func GetDay(w http.ResponseWriter, r *http.Request) { func getDay(w http.ResponseWriter, r *http.Request) {
dayString := chi.URLParam(r, "day") dayString := chi.URLParam(r, "day")
if dayString == "" { if dayString == "" {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("day not specified"))) util.HandleWrite(w.Write([]byte("day not specified")))
return return
} }
if dayString == config.Cfg.TodayDate() { // Today can still be edited if dayString == config.Cfg.TodayDate() { // Today can still be edited
@ -61,5 +62,5 @@ func GetDay(w http.ResponseWriter, r *http.Request) {
title = t.Format("02 Jan 2006") title = t.Format("02 Jan 2006")
} }
GetEntry(w, r, title, files.DataFile("day/"+dayString), false) getEntry(w, r, title, files.DataFile("day/"+dayString), false)
} }

View file

@ -1,4 +1,4 @@
package server package routes
import ( import (
"errors" "errors"
@ -25,8 +25,8 @@ type Entry struct {
type formatEntries func([]string) []Entry type formatEntries func([]string) []Entry
// GetEntries handles showing a list. // getEntries handles showing a list.
func GetEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) { func getEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) {
filesList, err := files.List(dir) filesList, err := files.List(dir)
if err != nil { if err != nil {
slog.Error("error reading file list", "directory", dir, "error", err) slog.Error("error reading file list", "directory", dir, "error", err)
@ -43,8 +43,8 @@ func GetEntries(w http.ResponseWriter, r *http.Request, title string, descriptio
} }
} }
// 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) { func getEntry(w http.ResponseWriter, r *http.Request, title string, filename string, editable bool) {
entry, err := files.Read(filename) entry, err := files.Read(filename)
if err != nil { if err != nil {
if editable && errors.Is(err, os.ErrNotExist) { if editable && errors.Is(err, os.ErrNotExist) {
@ -67,8 +67,8 @@ func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename str
} }
} }
// PostEntry saves value of "text" HTML form component to a file and redirects back to Referer if present. // 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) { func postEntry(filename string, w http.ResponseWriter, r *http.Request) {
err := files.Save(filename, []byte(r.FormValue("text"))) err := files.Save(filename, []byte(r.FormValue("text")))
if err != nil { if err != nil {
slog.Error("error saving file", "error", err, "file", filename) slog.Error("error saving file", "error", err, "file", filename)

View file

@ -1,4 +1,4 @@
package server package routes
import ( import (
"log/slog" "log/slog"
@ -26,7 +26,7 @@ func InternalError(w http.ResponseWriter, r *http.Request) {
err := templates.Template500.Execute(w, nil) err := templates.Template500.Execute(w, nil)
if err != nil { // Well this is awkward if err != nil { // Well this is awkward
slog.Error("error rendering error 500 page", "error", err) slog.Error("error rendering error 500 page", "error", err)
HandleWrite(w.Write([]byte("500. Something went *very* wrong."))) http.Error(w, "500. Something went *very* wrong.", http.StatusInternalServerError)
return return
} }
} }

View file

@ -0,0 +1,38 @@
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)
}

View file

@ -0,0 +1,28 @@
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
}
}

View file

@ -1,4 +1,4 @@
package server package routes
import ( import (
"html/template" "html/template"
@ -7,16 +7,17 @@ import (
"git.a71.su/Andrew71/hibiscus-txt/internal/files" "git.a71.su/Andrew71/hibiscus-txt/internal/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang" "git.a71.su/Andrew71/hibiscus-txt/internal/lang"
"git.a71.su/Andrew71/hibiscus-txt/internal/server/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// GetNotes calls GetEntries for all notes. // getNotes calls getEntries for all notes.
func GetNotes(w http.ResponseWriter, r *http.Request) { func getNotes(w http.ResponseWriter, r *http.Request) {
// This is suboptimal, but will do... // This is suboptimal, but will do...
description := template.HTML( description := template.HTML(
"<a href=\"#\" onclick='newNote(\"" + template.HTMLEscapeString(lang.Translate("prompt.notes")) + "\")'>" + template.HTMLEscapeString(lang.Translate("button.notes")) + "</a>" + "<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>") " <noscript>(" + template.HTMLEscapeString(lang.Translate("noscript.notes")) + ")</noscript>")
GetEntries(w, r, lang.Translate("title.notes"), description, "notes", func(files []string) []Entry { getEntries(w, r, lang.Translate("title.notes"), description, "notes", func(files []string) []Entry {
var filesFormatted []Entry var filesFormatted []Entry
for _, v := range files { for _, v := range files {
// titleString := strings.Replace(v, "-", " ", -1) // This would be cool, but what if I need a hyphen? // titleString := strings.Replace(v, "-", " ", -1) // This would be cool, but what if I need a hyphen?
@ -26,12 +27,12 @@ func GetNotes(w http.ResponseWriter, r *http.Request) {
}) })
} }
// GetNote calls GetEntry for a note. // getNote calls getEntry for a note.
func GetNote(w http.ResponseWriter, r *http.Request) { func getNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note") noteString := chi.URLParam(r, "note")
if noteString == "" { if noteString == "" {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("note not specified"))) util.HandleWrite(w.Write([]byte("note not specified")))
return return
} }
// Handle non-latin note names // Handle non-latin note names
@ -39,16 +40,16 @@ func GetNote(w http.ResponseWriter, r *http.Request) {
noteString = decodedNote noteString = decodedNote
} }
GetEntry(w, r, noteString, files.DataFile("notes/"+noteString), true) getEntry(w, r, noteString, files.DataFile("notes/"+noteString), true)
} }
// PostNote calls PostEntry for a note. // postNote calls postEntry for a note.
func PostNote(w http.ResponseWriter, r *http.Request) { func postNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note") noteString := chi.URLParam(r, "note")
if noteString == "" { if noteString == "" {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("note not specified"))) util.HandleWrite(w.Write([]byte("note not specified")))
return return
} }
PostEntry(files.DataFile("notes/"+noteString), w, r) postEntry(files.DataFile("notes/"+noteString), w, r)
} }

View file

@ -1,78 +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/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// public contains the static files e.g. CSS, JS.
//
//go:embed public
var public embed.FS
// Serve starts the app's web server.
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, 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)
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(files.DataFile("day/"+config.Cfg.TodayDate()), w)
})
apiRouter.Post("/today", func(w http.ResponseWriter, r *http.Request) {
PostEntry(files.DataFile("day/"+config.Cfg.TodayDate()), w, r)
})
apiRouter.Get("/export", files.GetExport)
apiRouter.Get("/grace", GraceActiveApi)
apiRouter.Get("/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", config.Cfg.Port)
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(config.Cfg.Port), r))
}

View file

@ -0,0 +1,11 @@
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)
}
}

View file

@ -19,6 +19,7 @@ func EmbeddedPage(name string) []byte {
data, err := pages.ReadFile(name) data, err := pages.ReadFile(name)
if err != nil { if err != nil {
slog.Error("error reading embedded file", "err", err) slog.Error("error reading embedded file", "err", err)
return []byte("")
} }
return data return data
} }