Refactor everything
This commit is contained in:
parent
b56ce43c80
commit
57903d4724
45 changed files with 514 additions and 416 deletions
37
internal/app/flags.go
Normal file
37
internal/app/flags.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/logging"
|
||||
)
|
||||
|
||||
// FlagInit processes app flags.
|
||||
func FlagInit() {
|
||||
conf := flag.String("config", "", "override config file")
|
||||
username := flag.String("user", "", "override username")
|
||||
password := flag.String("pass", "", "override password")
|
||||
port := flag.Int("port", 0, "override port")
|
||||
debug := flag.Bool("debug", false, "debug logging")
|
||||
|
||||
flag.Parse()
|
||||
if *conf != "" {
|
||||
config.ConfigFile = *conf
|
||||
err := config.Cfg.Reload()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
if *username != "" {
|
||||
config.Cfg.Username = *username
|
||||
}
|
||||
if *password != "" {
|
||||
config.Cfg.Password = *password
|
||||
}
|
||||
if *port != 0 {
|
||||
config.Cfg.Port = *port
|
||||
}
|
||||
logging.DebugMode = *debug
|
||||
}
|
12
internal/app/main.go
Normal file
12
internal/app/main.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/logging"
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/server"
|
||||
)
|
||||
|
||||
func Execute() {
|
||||
FlagInit()
|
||||
logging.LogInit()
|
||||
server.Serve()
|
||||
}
|
12
internal/config/info.go
Normal file
12
internal/config/info.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
package config
|
||||
|
||||
type AppInfo struct {
|
||||
Version string
|
||||
SourceLink string
|
||||
}
|
||||
|
||||
// Info contains app information.
|
||||
var Info = AppInfo{
|
||||
Version: "2.0.0",
|
||||
SourceLink: "https://git.a71.su/Andrew71/hibiscus",
|
||||
}
|
170
internal/config/main.go
Normal file
170
internal/config/main.go
Normal file
|
@ -0,0 +1,170 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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"
|
||||
|
||||
type Config struct {
|
||||
Username string `config:"username" type:"string" mandatory:"true"`
|
||||
Password string `config:"password" type:"string" mandatory:"true"`
|
||||
Port int `config:"port" type:"int" mandatory:"true"`
|
||||
Timezone *time.Location `config:"timezone" type:"location" mandatory:"true"`
|
||||
GraceTime time.Duration `config:"grace_period" type:"duration"`
|
||||
Language string `config:"language" type:"string" mandatory:"true"`
|
||||
Theme string `config:"theme" type:"string"`
|
||||
Title string `config:"title" type:"string"`
|
||||
LogToFile bool `config:"log_to_file" type:"bool"`
|
||||
LogFile string `config:"log_file" type:"string"`
|
||||
Scram bool `config:"enable_scram" type:"bool"`
|
||||
|
||||
TelegramToken string `config:"tg_token" type:"string"`
|
||||
TelegramChat string `config:"tg_chat" type:"string"`
|
||||
TelegramTopic string `config:"tg_topic" type:"string"`
|
||||
}
|
||||
|
||||
var DefaultConfig = Config{
|
||||
Username: "admin",
|
||||
Password: "admin",
|
||||
Port: 7101,
|
||||
Timezone: time.Local,
|
||||
GraceTime: 0,
|
||||
Language: "en",
|
||||
Theme: "",
|
||||
Title: "🌺 Hibiscus.txt",
|
||||
LogToFile: false,
|
||||
LogFile: "config/log.txt",
|
||||
Scram: false,
|
||||
|
||||
TelegramToken: "",
|
||||
TelegramChat: "",
|
||||
TelegramTopic: "",
|
||||
}
|
||||
|
||||
// String returns string representation of modified and mandatory config options.
|
||||
func (c *Config) String() string {
|
||||
output := ""
|
||||
v := reflect.ValueOf(*c)
|
||||
vDefault := reflect.ValueOf(DefaultConfig)
|
||||
typeOfS := v.Type()
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
key := typeOfS.Field(i).Tag.Get("config")
|
||||
value := v.Field(i).Interface()
|
||||
mandatory := typeOfS.Field(i).Tag.Get("mandatory")
|
||||
if (mandatory == "true") || (value != vDefault.Field(i).Interface()) { // Only save non-default values
|
||||
output += fmt.Sprintf("%s=%v\n", key, value)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
// Reload resets, then loads config from the ConfigFile.
|
||||
// It creates the file with mandatory options if it is missing.
|
||||
func (c *Config) Reload() error {
|
||||
*c = DefaultConfig // Reset config
|
||||
|
||||
if _, err := os.Stat(ConfigFile); errors.Is(err, os.ErrNotExist) {
|
||||
err := c.Save()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(ConfigFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
options := map[string]string{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
entry := strings.Split(strings.Trim(scanner.Text(), " \t"), "=")
|
||||
if len(entry) != 2 {
|
||||
continue
|
||||
}
|
||||
options[entry[0]] = entry[1]
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = file.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
timezone := "Local" // Timezone is handled separately because reflection
|
||||
refStruct := reflect.ValueOf(*c)
|
||||
refElem := reflect.ValueOf(&c).Elem()
|
||||
typeOfS := refStruct.Type()
|
||||
for i := 0; i < refStruct.NumField(); i++ {
|
||||
fieldElem := reflect.Indirect(refElem).Field(i)
|
||||
key := typeOfS.Field(i).Tag.Get("config")
|
||||
if v, ok := options[key]; ok && fieldElem.CanSet() {
|
||||
switch typeOfS.Field(i).Tag.Get("type") {
|
||||
case "int":
|
||||
{
|
||||
numVal, err := strconv.Atoi(v)
|
||||
if err == nil {
|
||||
fieldElem.SetInt(int64(numVal))
|
||||
}
|
||||
}
|
||||
case "bool":
|
||||
fieldElem.SetBool(v == "true")
|
||||
case "location":
|
||||
timezone = v
|
||||
case "duration":
|
||||
{
|
||||
numVal, err := time.ParseDuration(v)
|
||||
if err == nil {
|
||||
fieldElem.SetInt(int64(numVal))
|
||||
}
|
||||
}
|
||||
default:
|
||||
fieldElem.SetString(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
c.Timezone = time.Local
|
||||
} else {
|
||||
c.Timezone = loc
|
||||
}
|
||||
slog.Debug("reloaded config", "config", c)
|
||||
|
||||
return lang.SetLanguage(c.Language) // Load selected language
|
||||
}
|
||||
|
||||
// Read gets raw contents from ConfigFile.
|
||||
func (c *Config) Read() ([]byte, error) {
|
||||
return files.Read(ConfigFile)
|
||||
}
|
||||
|
||||
// Save writes config's contents to the ConfigFile.
|
||||
func (c *Config) Save() error {
|
||||
return files.Save(ConfigFile, []byte(c.String()))
|
||||
}
|
||||
|
||||
// ConfigInit loads config on startup.
|
||||
func ConfigInit() Config {
|
||||
cfg := Config{}
|
||||
err := cfg.Reload()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
31
internal/config/time.go
Normal file
31
internal/config/time.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FIXME: This probably shouldn't be part of config package
|
||||
|
||||
// Grace returns whether the grace period is active.
|
||||
// NOTE: Grace period has minute precision
|
||||
func (c Config) Grace() bool {
|
||||
t := time.Now().In(c.Timezone)
|
||||
active := (60*t.Hour() + t.Minute()) < int(c.GraceTime.Minutes())
|
||||
if active {
|
||||
slog.Debug("grace period active",
|
||||
"time", 60*t.Hour()+t.Minute(),
|
||||
"grace", c.GraceTime.Minutes())
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
// TodayDate returns today's formatted date. It accounts for Config.GraceTime.
|
||||
func (c Config) TodayDate() string {
|
||||
dateFormatted := time.Now().In(c.Timezone).Format(time.DateOnly)
|
||||
if c.Grace() {
|
||||
dateFormatted = time.Now().In(c.Timezone).AddDate(0, 0, -1).Format(time.DateOnly)
|
||||
}
|
||||
slog.Debug("today", "time", time.Now().In(c.Timezone).Format(time.DateTime))
|
||||
return dateFormatted
|
||||
}
|
74
internal/files/export.go
Normal file
74
internal/files/export.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var ExportPath = "data/export.zip" // TODO: Move to config
|
||||
|
||||
// Export saves a .zip archive of the data folder to a file.
|
||||
func Export(filename string) error {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
slog.Error("error creating export archive", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
w := zip.NewWriter(file)
|
||||
walker := func(path string, info os.FileInfo, err error) error {
|
||||
if path == filename || filepath.Ext(path) == ".zip" { //Ignore export file itself and .zip archives
|
||||
return nil
|
||||
}
|
||||
slog.Debug("export crawling", "path", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := w.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(f, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return file.Close()
|
||||
}
|
||||
err = filepath.Walk("data/", walker)
|
||||
if err != nil {
|
||||
slog.Error("error walking files", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
// GetExport returns a .zip archive with contents of the data folder.
|
||||
// As a side effect, it creates the file in there.
|
||||
func GetExport(w http.ResponseWriter, r *http.Request) {
|
||||
err := Export(ExportPath)
|
||||
if err != nil {
|
||||
slog.Error("error getting export archive", "error", err)
|
||||
http.Error(w, "could not export", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "data/export.zip")
|
||||
}
|
68
internal/files/files.go
Normal file
68
internal/files/files.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DataFile modifies file path to ensure it's a .txt inside the data folder.
|
||||
func DataFile(filename string) string {
|
||||
return "data/" + path.Clean(filename) + ".txt"
|
||||
}
|
||||
|
||||
// Read returns contents of a file.
|
||||
func Read(filename string) ([]byte, error) {
|
||||
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
fileContents, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
slog.Error("error reading file", "error", err, "file", filename)
|
||||
return nil, err
|
||||
}
|
||||
return fileContents, nil
|
||||
}
|
||||
|
||||
// Save Writes contents to a file.
|
||||
func Save(filename string, contents []byte) error {
|
||||
contents = bytes.TrimSpace(contents)
|
||||
if len(contents) == 0 { // Delete empty files
|
||||
err := os.Remove(filename)
|
||||
slog.Error("error deleting empty file", "error", err, "file", filename)
|
||||
return err
|
||||
}
|
||||
err := os.MkdirAll(path.Dir(filename), 0755) // Create dir in case it doesn't exist yet to avoid errors
|
||||
if err != nil {
|
||||
slog.Error("error creating directory", "error", err, "file", filename)
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
slog.Error("error opening/creating file", "error", err, "file", filename)
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(contents); err != nil {
|
||||
slog.Error("error writing to file", "error", err, "file", filename)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns slice of filenames in a directory without extensions or path.
|
||||
// NOTE: What if I ever want to list non-text files or those outside data directory?
|
||||
func List(directory string) ([]string, error) {
|
||||
filenames, err := filepath.Glob("data/" + path.Clean(directory) + "/*.txt")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, file := range filenames {
|
||||
file, _ := strings.CutSuffix(filepath.Base(file), filepath.Ext(file))
|
||||
filenames[i] = file
|
||||
}
|
||||
return filenames, nil
|
||||
}
|
35
internal/lang/lang/en.json
Normal file
35
internal/lang/lang/en.json
Normal file
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"lang": "en-UK",
|
||||
|
||||
"title.today": "Your day so far",
|
||||
"title.days": "Previous days",
|
||||
"title.notes": "Notes",
|
||||
"title.info": "Info",
|
||||
|
||||
"link.today": "today",
|
||||
"link.tomorrow": "tomorrow",
|
||||
"link.days": "previous days",
|
||||
"link.notes": "notes",
|
||||
"link.info": "app information",
|
||||
|
||||
"time.date": "Today is",
|
||||
"time.grace": "grace period active",
|
||||
"button.save": "Save",
|
||||
"button.notes": "New note",
|
||||
"prompt.notes": "Note name",
|
||||
"noscript.notes": "/notes/<name>",
|
||||
"prompt.days": "To the bottom",
|
||||
|
||||
"info.version": "Version",
|
||||
"info.version.link": "source and changelog",
|
||||
"info.export": "Export data",
|
||||
"info.readme": "Edit readme.txt",
|
||||
"info.config": "Edit config",
|
||||
|
||||
"info.telegram.auth_fail": "Failed auth attempt in Hibiscus.txt",
|
||||
"info.telegram.scram": "Hibiscus SCRAM triggered, shutting down",
|
||||
|
||||
"error.404": "The page you were looking for doesn't exist",
|
||||
"error.500": "It's probably not your fault, but something broke",
|
||||
"error.prompt": "Go home?"
|
||||
}
|
35
internal/lang/lang/ru.json
Normal file
35
internal/lang/lang/ru.json
Normal file
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"lang": "ru",
|
||||
|
||||
"title.today": "Сегодняшний день",
|
||||
"title.days": "Предыдущие дни",
|
||||
"title.notes": "Заметки",
|
||||
"title.info": "Информация",
|
||||
|
||||
"link.today": "сегодня",
|
||||
"link.tomorrow": "завтра",
|
||||
"link.days": "раньше",
|
||||
"link.notes": "заметки",
|
||||
"link.info": "системная информация",
|
||||
|
||||
"time.date": "Сегодня",
|
||||
"time.grace": "редактируется вчерашний день",
|
||||
"button.save": "Сохранить",
|
||||
"button.notes": "Новая заметка",
|
||||
"prompt.notes": "Название заметки",
|
||||
"noscript.notes": "/notes/<название>",
|
||||
"prompt.days": "Вниз",
|
||||
|
||||
"info.version": "Версия",
|
||||
"info.version.link": "исходный код",
|
||||
"info.export": "Экспорт данных",
|
||||
"info.readme": "Редактировать readme.txt",
|
||||
"info.config": "Редактировать конфиг",
|
||||
|
||||
"info.telegram_notification": "Неверная попытка авторизации в Hibiscus.txt",
|
||||
"info.telegram.scram": "Активирована функция SCRAM в Hibiscus.txt, сервер выключается",
|
||||
|
||||
"error.404": "Страница, которую вы ищете, не существует",
|
||||
"error.500": "Что-то сломалось",
|
||||
"error.prompt": "На главную?"
|
||||
}
|
41
internal/lang/main.go
Normal file
41
internal/lang/main.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package lang
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
//go:embed lang
|
||||
var lang embed.FS
|
||||
var translations = map[string]string{}
|
||||
|
||||
// SetLanguage loads a json file for selected language into the Translations map, with English language as a fallback.
|
||||
func SetLanguage(language string) error {
|
||||
loadLanguage := func(language string) error {
|
||||
filename := "lang/" + language + ".json"
|
||||
fileContents, err := lang.ReadFile(filename)
|
||||
if err != nil {
|
||||
slog.Error("error reading language file",
|
||||
"error", err,
|
||||
"file", filename)
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(fileContents, &translations)
|
||||
}
|
||||
translations = map[string]string{} // Clear the map to avoid previous language remaining
|
||||
err := loadLanguage("en") // Load English as fallback
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return loadLanguage(language)
|
||||
}
|
||||
|
||||
// Translate attempts to match an id to a string in current language.
|
||||
func Translate(id string) string {
|
||||
if v, ok := translations[id]; !ok {
|
||||
return id
|
||||
} else {
|
||||
return v
|
||||
}
|
||||
}
|
37
internal/logging/logger.go
Normal file
37
internal/logging/logger.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
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 config.Cfg.LogToFile {
|
||||
f, err := os.OpenFile(config.Cfg.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", config.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})
|
||||
}
|
112
internal/server/api.go
Normal file
112
internal/server/api.go
Normal file
|
@ -0,0 +1,112 @@
|
|||
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)
|
||||
}
|
||||
|
108
internal/server/auth.go
Normal file
108
internal/server/auth.go
Normal file
|
@ -0,0 +1,108 @@
|
|||
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)
|
||||
}
|
||||
}
|
31
internal/server/config.go
Normal file
31
internal/server/config.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
||||
)
|
||||
|
||||
// PostConfig calls PostEntry for config file, then reloads the config.
|
||||
func PostConfig(w http.ResponseWriter, r *http.Request) {
|
||||
PostEntry(config.ConfigFile, w, r)
|
||||
err := config.Cfg.Reload()
|
||||
if err != nil {
|
||||
slog.Error("error reloading config", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigReloadApi reloads the config. It then redirects back if Referer field is present.
|
||||
func ConfigReloadApi(w http.ResponseWriter, r *http.Request) {
|
||||
err := config.Cfg.Reload()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
HandleWrite(w.Write([]byte(err.Error())))
|
||||
}
|
||||
if r.Referer() != "" {
|
||||
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
65
internal/server/days.go
Normal file
65
internal/server/days.go
Normal file
|
@ -0,0 +1,65 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// GetDays calls GetEntries for previous days' entries.
|
||||
func GetDays(w http.ResponseWriter, r *http.Request) {
|
||||
description := template.HTML(
|
||||
"<a href=\"#footer\">" + template.HTMLEscapeString(lang.Translate("prompt.days")) + "</a>")
|
||||
GetEntries(w, r, lang.Translate("title.days"), description, "day", func(files []string) []Entry {
|
||||
var filesFormatted []Entry
|
||||
for i := range files {
|
||||
v := files[len(files)-1-i] // This is suboptimal, but reverse order is better here
|
||||
dayString := v
|
||||
t, err := time.Parse(time.DateOnly, v)
|
||||
if err == nil {
|
||||
dayString = t.Format("02 Jan 2006")
|
||||
}
|
||||
|
||||
// Fancy text for today and tomorrow
|
||||
// This looks bad, but strings.Title is deprecated, and I'm not importing a golang.org/x package for this...
|
||||
// (chances we ever run into tomorrow are really low)
|
||||
if v == config.Cfg.TodayDate() {
|
||||
dayString = lang.Translate("link.today")
|
||||
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
|
||||
} else if v > config.Cfg.TodayDate() {
|
||||
dayString = lang.Translate("link.tomorrow")
|
||||
dayString = strings.ToTitle(string([]rune(dayString)[0])) + string([]rune(dayString)[1:])
|
||||
}
|
||||
filesFormatted = append(filesFormatted, Entry{Title: dayString, Link: "day/" + v})
|
||||
}
|
||||
return filesFormatted
|
||||
})
|
||||
}
|
||||
|
||||
// GetDay calls GetEntry for a day entry.
|
||||
func GetDay(w http.ResponseWriter, r *http.Request) {
|
||||
dayString := chi.URLParam(r, "day")
|
||||
if dayString == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
HandleWrite(w.Write([]byte("day not specified")))
|
||||
return
|
||||
}
|
||||
if dayString == config.Cfg.TodayDate() { // Today can still be edited
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
title := dayString
|
||||
t, err := time.Parse(time.DateOnly, dayString)
|
||||
if err == nil { // This is low priority so silently fail
|
||||
title = t.Format("02 Jan 2006")
|
||||
}
|
||||
|
||||
GetEntry(w, r, title, files.DataFile("day/"+dayString), false)
|
||||
}
|
80
internal/server/entries.go
Normal file
80
internal/server/entries.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/templates"
|
||||
)
|
||||
|
||||
type EntryList struct {
|
||||
Title string
|
||||
Description template.HTML
|
||||
Entries []Entry
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
Title string
|
||||
Content string
|
||||
Link string
|
||||
}
|
||||
|
||||
type formatEntries func([]string) []Entry
|
||||
|
||||
// GetEntries handles showing a list.
|
||||
func GetEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) {
|
||||
filesList, err := files.List(dir)
|
||||
if err != nil {
|
||||
slog.Error("error reading file list", "directory", dir, "error", err)
|
||||
InternalError(w, r)
|
||||
return
|
||||
}
|
||||
var filesFormatted = format(filesList)
|
||||
|
||||
err = templates.List.ExecuteTemplate(w, "base", EntryList{Title: title, Description: description, Entries: filesFormatted})
|
||||
if err != nil {
|
||||
slog.Error("error executing template", "error", err)
|
||||
InternalError(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GetEntry handles showing a single file, editable or otherwise.
|
||||
func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename string, editable bool) {
|
||||
entry, err := files.Read(filename)
|
||||
if err != nil {
|
||||
if editable && errors.Is(err, os.ErrNotExist) {
|
||||
entry = []byte("")
|
||||
} else {
|
||||
slog.Error("error reading entry file", "error", err, "file", filename)
|
||||
InternalError(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if editable {
|
||||
err = templates.Edit.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)})
|
||||
} else {
|
||||
err = templates.View.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)})
|
||||
}
|
||||
if err != nil {
|
||||
InternalError(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// PostEntry saves value of "text" HTML form component to a file and redirects back to Referer if present.
|
||||
func PostEntry(filename string, w http.ResponseWriter, r *http.Request) {
|
||||
err := files.Save(filename, []byte(r.FormValue("text")))
|
||||
if err != nil {
|
||||
slog.Error("error saving file", "error", err, "file", filename)
|
||||
}
|
||||
if r.Referer() != "" {
|
||||
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
32
internal/server/errors.go
Normal file
32
internal/server/errors.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/templates"
|
||||
)
|
||||
|
||||
// NotFound returns a user-friendly 404 error page.
|
||||
func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
|
||||
err := templates.Template404.Execute(w, nil)
|
||||
if err != nil {
|
||||
slog.Error("error rendering error 404 page", "error", err)
|
||||
InternalError(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// InternalError returns a user-friendly 500 error page.
|
||||
func InternalError(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
|
||||
err := templates.Template500.Execute(w, nil)
|
||||
if err != nil { // Well this is awkward
|
||||
slog.Error("error rendering error 500 page", "error", err)
|
||||
HandleWrite(w.Write([]byte("500. Something went *very* wrong.")))
|
||||
return
|
||||
}
|
||||
}
|
25
internal/server/info.go
Normal file
25
internal/server/info.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/templates"
|
||||
)
|
||||
|
||||
// GetInfo renders the info page.
|
||||
func GetInfo(w http.ResponseWriter, r *http.Request) {
|
||||
err := templates.Info.ExecuteTemplate(w, "base", config.Info)
|
||||
if err != nil {
|
||||
slog.Error("error executing template", "error", err)
|
||||
InternalError(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GetVersionApi returns current app version.
|
||||
func GetVersionApi(w http.ResponseWriter, r *http.Request) {
|
||||
HandleWrite(w.Write([]byte(config.Info.Version)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
54
internal/server/notes.go
Normal file
54
internal/server/notes.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// GetNotes calls GetEntries for all notes.
|
||||
func GetNotes(w http.ResponseWriter, r *http.Request) {
|
||||
// This is suboptimal, but will do...
|
||||
description := template.HTML(
|
||||
"<a href=\"#\" onclick='newNote(\"" + template.HTMLEscapeString(lang.Translate("prompt.notes")) + "\")'>" + template.HTMLEscapeString(lang.Translate("button.notes")) + "</a>" +
|
||||
" <noscript>(" + template.HTMLEscapeString(lang.Translate("noscript.notes")) + ")</noscript>")
|
||||
GetEntries(w, r, lang.Translate("title.notes"), description, "notes", func(files []string) []Entry {
|
||||
var filesFormatted []Entry
|
||||
for _, v := range files {
|
||||
// titleString := strings.Replace(v, "-", " ", -1) // This would be cool, but what if I need a hyphen?
|
||||
filesFormatted = append(filesFormatted, Entry{Title: v, Link: "notes/" + v})
|
||||
}
|
||||
return filesFormatted
|
||||
})
|
||||
}
|
||||
|
||||
// GetNote calls GetEntry for a note.
|
||||
func GetNote(w http.ResponseWriter, r *http.Request) {
|
||||
noteString := chi.URLParam(r, "note")
|
||||
if noteString == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
HandleWrite(w.Write([]byte("note not specified")))
|
||||
return
|
||||
}
|
||||
// Handle non-latin note names
|
||||
if decodedNote, err := url.QueryUnescape(noteString); err == nil {
|
||||
noteString = decodedNote
|
||||
}
|
||||
|
||||
GetEntry(w, r, noteString, files.DataFile("notes/"+noteString), true)
|
||||
}
|
||||
|
||||
// PostNote calls PostEntry for a note.
|
||||
func PostNote(w http.ResponseWriter, r *http.Request) {
|
||||
noteString := chi.URLParam(r, "note")
|
||||
if noteString == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
HandleWrite(w.Write([]byte("note not specified")))
|
||||
return
|
||||
}
|
||||
PostEntry(files.DataFile("notes/"+noteString), w, r)
|
||||
}
|
BIN
internal/server/public/favicon-512.png
Normal file
BIN
internal/server/public/favicon-512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
BIN
internal/server/public/favicon.ico
Normal file
BIN
internal/server/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 894 B |
117
internal/server/public/main.css
Normal file
117
internal/server/public/main.css
Normal file
|
@ -0,0 +1,117 @@
|
|||
/* Default theme */
|
||||
:root {
|
||||
/* Light theme */
|
||||
--text-light: #2b2a2a;
|
||||
--bg-light: #f4edd7;
|
||||
|
||||
--clickable-light: #ed3e3b;
|
||||
--clickable-hover-light: #e55552;
|
||||
--clickable-label-light: #f4edd7;
|
||||
--text-hover-light: #656565;
|
||||
|
||||
--textarea-bg-light: #f9f5e4;
|
||||
--textarea-border-light: #c3c3c2;
|
||||
|
||||
/* Dark theme */
|
||||
--text-dark: #f5f0e1;
|
||||
--bg-dark: #1b1916;
|
||||
|
||||
--clickable-dark: #ed3e3b;
|
||||
--clickable-hover-dark: #ae3836;
|
||||
--clickable-label-dark: #f5f2ee;
|
||||
--text-hover-dark: #a9a8a4;
|
||||
|
||||
--textarea-bg-dark: #201d1b; /* 252020 f5f0e1 */
|
||||
--textarea-border-dark: #2c2727;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
color: var(--text-light);
|
||||
background-color: var(--bg-light);
|
||||
font-size: 18px;
|
||||
margin: auto auto;
|
||||
max-width: 640px;
|
||||
padding: 15px;
|
||||
line-height: 1.4;
|
||||
font-family: serif;
|
||||
min-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
h1,h2,h3,h4,h5,h6 { line-height: 1.2 }
|
||||
|
||||
a, a:visited { color: var(--clickable-light); }
|
||||
a:hover, a:visited:hover { color: var(--clickable-hover-light); }
|
||||
a.no-accent, a.no-accent:visited { color: var(--text-light); }
|
||||
a.no-accent:hover, a.no-accent:visited:hover { color: var(--text-hover-light); }
|
||||
|
||||
ul:not(li ul), ol:not(li ol){
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.list-title { margin-bottom: 0}
|
||||
.list-desc { margin-top: 0 }
|
||||
|
||||
textarea, input {
|
||||
background: var(--textarea-bg-light);
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
display: block;
|
||||
resize: vertical;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
border: 2px solid var(--textarea-border-light);
|
||||
margin-bottom: 1em;
|
||||
font-size: 18px;
|
||||
}
|
||||
input { height: 2.5em; }
|
||||
|
||||
button {
|
||||
background-color: var(--clickable-light);
|
||||
border: none;
|
||||
color: var(--clickable-label-light);
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
margin: 4px 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
}
|
||||
button:hover { background-color: var(--clickable-hover-light); }
|
||||
|
||||
footer { margin-top: auto; }
|
||||
header > h1, header > p {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
color: var(--text-dark);
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
textarea, input {
|
||||
color: var(--text-dark);
|
||||
background-color: var(--textarea-bg-dark);
|
||||
border-color: var(--textarea-border-dark)
|
||||
}
|
||||
|
||||
a, a:visited { color: var(--clickable-dark); }
|
||||
a:hover, a:visited:hover { color: var(--clickable-hover-dark); }
|
||||
a.no-accent, a.no-accent:visited { color: var(--text-dark); }
|
||||
a.no-accent:hover, a.no-accent:visited:hover { color: var(--text-hover-dark); }
|
||||
|
||||
button {
|
||||
background-color: var(--clickable-dark);
|
||||
color: var(--clickable-label-dark);
|
||||
}
|
||||
button:hover { background-color: var(--clickable-hover-dark); }
|
||||
}
|
50
internal/server/public/main.js
Normal file
50
internal/server/public/main.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
// Format time in "Jan 02, 2006" format
|
||||
function formatDate(date) {
|
||||
let options = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}
|
||||
if (timeZone !== "Local") { options.timeZone = timeZone }
|
||||
let dateFormat = new Intl.DateTimeFormat([langName, "en"], options)
|
||||
return dateFormat.format(date)
|
||||
}
|
||||
|
||||
async function graceActive() {
|
||||
const response = await fetch("/api/grace");
|
||||
const active = await response.text();
|
||||
return active === "true"
|
||||
}
|
||||
|
||||
// Set today's date and grace status
|
||||
function updateTime() {
|
||||
document.getElementById("today-date").innerText = formatDate(Date.now());
|
||||
graceActive().then(v => document.getElementById("grace").hidden = !v)
|
||||
}
|
||||
|
||||
// Start interval to update time at start of every minute
|
||||
function callEveryMinute() {
|
||||
setInterval(updateTime, 1000 * 60);
|
||||
}
|
||||
|
||||
// Begin above interval
|
||||
function beginTimeUpdater() {
|
||||
const difference = (60 - new Date().getSeconds()) * 1000;
|
||||
setTimeout(callEveryMinute, difference);
|
||||
setTimeout(updateTime, difference);
|
||||
updateTime();
|
||||
}
|
||||
|
||||
// This does NOT properly sanitize, and assumes a well-meaning user
|
||||
function sanitize(title) {
|
||||
return title
|
||||
.trim()
|
||||
.replace(/ +/g, '-')
|
||||
.replace(/[!*'();:@&=+$,\/?#\[\]]/g, '')
|
||||
}
|
||||
|
||||
// Open a new note
|
||||
function newNote(text_prompt) {
|
||||
name = sanitize(prompt(text_prompt + ':'))
|
||||
window.location.replace('/notes/' + name)
|
||||
}
|
22
internal/server/public/manifest.json
Normal file
22
internal/server/public/manifest.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"short_name": "Hibiscus",
|
||||
"name": "Hibiscus.txt",
|
||||
"description": "A plaintext diary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/public/favicon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "/public/favicon.ico",
|
||||
"type": "image/x-icon",
|
||||
"sizes": "16x16"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"display": "fullscreen",
|
||||
"scope": "/",
|
||||
"background_color": "#f5f0e1",
|
||||
"theme_color": "#f85552"
|
||||
}
|
26
internal/server/public/themes/gruvbox.css
Normal file
26
internal/server/public/themes/gruvbox.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
/* Based on https://github.com/morhetz/gruvbox, MIT licensed colorscheme for vim */
|
||||
:root {
|
||||
/* Light theme */
|
||||
--text-light: #3c3836;
|
||||
--bg-light: #fbf1c7;
|
||||
|
||||
--clickable-light: #cc241d;
|
||||
--clickable-hover-light: #9d0006;
|
||||
--clickable-label-light: #f9f5d7;
|
||||
--text-hover-light: #665c54;
|
||||
|
||||
--textarea-bg-light: #f9f5d7;
|
||||
--textarea-border-light: #282828;
|
||||
|
||||
/* Dark theme */
|
||||
--text-dark: #ebdbb2;
|
||||
--bg-dark: #282828;
|
||||
|
||||
--clickable-dark: #cc241d;
|
||||
--clickable-hover-dark: #fb4934;
|
||||
--clickable-label-dark: #fbf1c7;
|
||||
--text-hover-dark: #fbf1c7;
|
||||
|
||||
--textarea-bg-dark: #32302f;
|
||||
--textarea-border-dark: #3c3836;
|
||||
}
|
26
internal/server/public/themes/high-contrast.css
Normal file
26
internal/server/public/themes/high-contrast.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
/* High contrast theme. It ain't pretty, but it passes WCAG AA and mostly even AAA */
|
||||
:root {
|
||||
/* Light theme */
|
||||
--text-light: #000000;
|
||||
--bg-light: #FFFFFF;
|
||||
|
||||
--clickable-light: #CC0000;
|
||||
--clickable-hover-light: #CC3333;
|
||||
--clickable-label-light: #FFFFFF;
|
||||
--text-hover-light: #666666;
|
||||
|
||||
--textarea-bg-light: #FFFFFF;
|
||||
--textarea-border-light: #000000;
|
||||
|
||||
/* Dark theme */
|
||||
--text-dark: #FFFFFF;
|
||||
--bg-dark: #000000;
|
||||
|
||||
--clickable-dark: #FF3333;
|
||||
--clickable-hover-dark: #FF6666;
|
||||
--clickable-label-dark: #000000;
|
||||
--text-hover-dark: #e7e7e7;
|
||||
|
||||
--textarea-bg-dark: #000000;
|
||||
--textarea-border-dark: #666666;
|
||||
}
|
26
internal/server/public/themes/lavender.css
Normal file
26
internal/server/public/themes/lavender.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
/* Tell me a secret. */
|
||||
:root {
|
||||
/* Light theme */
|
||||
--text-light: #3c3836;
|
||||
--bg-light: #e6dffa; /* d4c7fb*/
|
||||
|
||||
--clickable-light: #9975f5;
|
||||
--clickable-hover-light: #765bef;
|
||||
--clickable-label-light: #e2d8ff;
|
||||
--text-hover-light: #665c54;
|
||||
|
||||
--textarea-bg-light: #f3ecff;
|
||||
--textarea-border-light: #282828;
|
||||
|
||||
/* Dark theme */
|
||||
--text-dark: #e6dffa;
|
||||
--bg-dark: #25252a;
|
||||
|
||||
--clickable-dark: #9975f5;
|
||||
--clickable-hover-dark: #765bef;
|
||||
--clickable-label-dark: #e2d8ff;
|
||||
--text-hover-dark: #a5a5b9;
|
||||
|
||||
--textarea-bg-dark: #27272f;
|
||||
--textarea-border-dark: #3c3836;
|
||||
}
|
30
internal/server/public/themes/sans.css
Normal file
30
internal/server/public/themes/sans.css
Normal file
|
@ -0,0 +1,30 @@
|
|||
/* Sans Undertale */
|
||||
:root {
|
||||
/* Light theme */
|
||||
--text-light: #3c3836;
|
||||
--bg-light: #e2e7e8;
|
||||
|
||||
--clickable-light: #41acda;
|
||||
--clickable-hover-light: #2d8db4;
|
||||
--clickable-label-light: #e2e8e2;
|
||||
--text-hover-light: #665c54;
|
||||
|
||||
--textarea-bg-light: #f3f3f3;
|
||||
--textarea-border-light: #3c3836;
|
||||
|
||||
/* Dark theme */
|
||||
--text-dark: #e2e7e8;
|
||||
--bg-dark: #25282a;
|
||||
|
||||
--clickable-dark: #41acda;
|
||||
--clickable-hover-dark: #2d8db4;
|
||||
--clickable-label-dark: #e2e8e2;
|
||||
--text-hover-dark: #cdd2d3;
|
||||
|
||||
--textarea-bg-dark: #2d3234;
|
||||
--textarea-border-dark: #3c3836;
|
||||
}
|
||||
|
||||
body, textarea, input, button {
|
||||
font-family: "Comic Sans MS", sans-serif;
|
||||
}
|
79
internal/server/serve.go
Normal file
79
internal/server/serve.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
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)
|
||||
slog.Debug("Debug mode enabled")
|
||||
log.Fatal(http.ListenAndServe(":"+strconv.Itoa(config.Cfg.Port), r))
|
||||
}
|
38
internal/templates/main.go
Normal file
38
internal/templates/main.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/config"
|
||||
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
|
||||
)
|
||||
|
||||
// pages contains the HTML templates used by the app.
|
||||
//
|
||||
//go:embed pages
|
||||
var pages embed.FS
|
||||
|
||||
// EmbeddedPage returns contents of a file in Pages while "handling" potential errors.
|
||||
func EmbeddedPage(name string) []byte {
|
||||
data, err := pages.ReadFile(name)
|
||||
if err != nil {
|
||||
slog.Error("error reading embedded file", "err", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
var templateFuncs = map[string]interface{}{
|
||||
"translate": lang.Translate,
|
||||
"info": func() config.AppInfo { return config.Info },
|
||||
"config": func() config.Config { return config.Cfg },
|
||||
}
|
||||
var Edit = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/edit.html"))
|
||||
var View = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/entry.html"))
|
||||
var List = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/list.html"))
|
||||
|
||||
var Info = template.Must(template.New("").Funcs(templateFuncs).ParseFS(pages, "pages/base.html", "pages/info.html"))
|
||||
|
||||
var Template404 = template.Must(template.New("404").Funcs(templateFuncs).ParseFS(pages, "pages/error/404.html"))
|
||||
var Template500 = template.Must(template.New("500").Funcs(templateFuncs).ParseFS(pages, "pages/error/500.html"))
|
41
internal/templates/pages/base.html
Normal file
41
internal/templates/pages/base.html
Normal file
|
@ -0,0 +1,41 @@
|
|||
{{ define "header" }}
|
||||
<header>
|
||||
<h1>{{ config.Title }}</h1>
|
||||
<p>{{ translate "time.date" }} <span id="today-date">a place</span> <span id="grace" hidden>({{ translate "time.grace" }})</span></p>
|
||||
</header>
|
||||
{{ end }}
|
||||
|
||||
{{- define "base" -}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ translate "lang" }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="manifest" href="/public/manifest.json" />
|
||||
<link rel="icon" type="image/x-icon" href="/public/favicon.ico">
|
||||
<link rel="stylesheet" href="/public/main.css">
|
||||
{{- if config.Theme -}}<link rel="stylesheet" href="/public/themes/{{ config.Theme }}.css">{{ end }}
|
||||
<script src="/public/main.js"></script>
|
||||
<title>Hibiscus.txt</title>
|
||||
</head>
|
||||
<body>
|
||||
{{- template "header" . -}}
|
||||
<main>
|
||||
{{- template "main" . -}}
|
||||
</main>
|
||||
{{- template "footer" . -}}
|
||||
<script defer>
|
||||
const langName="{{ config.Language }}";
|
||||
const timeZone="{{ config.Timezone }}";
|
||||
beginTimeUpdater()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
||||
{{ define "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>
|
||||
<span style="float:right;"><a class="no-accent" href="/info" title="{{ translate "link.info" }}">v{{ info.Version }}</a></span></p>
|
||||
</footer>
|
||||
{{ end }}
|
7
internal/templates/pages/edit.html
Normal file
7
internal/templates/pages/edit.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
{{ define "main" }}
|
||||
<form method="POST">
|
||||
<h2><label for="text">{{ .Title }}:</label></h2>
|
||||
<textarea id="text" cols="40" rows="15" name="text">{{ .Content }}</textarea>
|
||||
<button type="submit">{{ translate "button.save" }}</button>
|
||||
</form>
|
||||
{{ end }}
|
4
internal/templates/pages/entry.html
Normal file
4
internal/templates/pages/entry.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
{{ define "main" }}
|
||||
<h2><label for="text">{{ .Title }}</label></h2>
|
||||
<textarea id="text" cols="40" rows="15" readonly>{{ .Content }}</textarea>
|
||||
{{ end }}
|
19
internal/templates/pages/error/404.html
Normal file
19
internal/templates/pages/error/404.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{{- define "404" -}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="/public/favicon.ico">
|
||||
<link rel="stylesheet" href="/public/main.css">
|
||||
<title>Error 404</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Error 404 - Not Found</h1>
|
||||
<p>{{ translate "error.404" }}</p>
|
||||
<p><a href="/">{{ translate "error.prompt" }}</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
19
internal/templates/pages/error/500.html
Normal file
19
internal/templates/pages/error/500.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{{- define "500" -}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/x-icon" href="/public/favicon.ico">
|
||||
<link rel="stylesheet" href="/public/main.css">
|
||||
<title>Error 500</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Error 500 - Internal Server Error</h1>
|
||||
<p>{{ translate "error.500" }}</p>
|
||||
<p><a href="/">{{ translate "error.prompt" }}</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
9
internal/templates/pages/info.html
Normal file
9
internal/templates/pages/info.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{{ define "main" }}
|
||||
<h2>{{ translate "title.info" }}</h2>
|
||||
<ul>
|
||||
<li>{{ translate "info.version" }} - {{ info.Version }} (<a href="{{ .SourceLink }}">{{ translate "info.version.link" }}</a>)</li>
|
||||
<li><a href="/config">{{ translate "info.config" }}</a></li>
|
||||
<li><a href="/readme">{{ translate "info.readme" }}</a></li>
|
||||
<li><a href="/api/export" download="hibiscus">{{ translate "info.export" }}</a></li>
|
||||
</ul>
|
||||
{{ end }}
|
9
internal/templates/pages/list.html
Normal file
9
internal/templates/pages/list.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{{ define "main" }}
|
||||
<h2 class="list-title">{{ .Title }}</h2>
|
||||
<p class="list-desc">{{ .Description }}</p>
|
||||
<ul>
|
||||
{{ range .Entries }}
|
||||
<li><a href="/{{.Link}}">{{.Title}}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{end}}
|
Loading…
Add table
Add a link
Reference in a new issue