Refactor everything

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

112
internal/server/api.go Normal file
View 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
View 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
View file

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

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

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

View file

@ -0,0 +1,80 @@
package server
import (
"errors"
"html/template"
"log/slog"
"net/http"
"os"
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/templates"
)
type EntryList struct {
Title string
Description template.HTML
Entries []Entry
}
type Entry struct {
Title string
Content string
Link string
}
type formatEntries func([]string) []Entry
// GetEntries handles showing a list.
func GetEntries(w http.ResponseWriter, r *http.Request, title string, description template.HTML, dir string, format formatEntries) {
filesList, err := files.List(dir)
if err != nil {
slog.Error("error reading file list", "directory", dir, "error", err)
InternalError(w, r)
return
}
var filesFormatted = format(filesList)
err = templates.List.ExecuteTemplate(w, "base", EntryList{Title: title, Description: description, Entries: filesFormatted})
if err != nil {
slog.Error("error executing template", "error", err)
InternalError(w, r)
return
}
}
// GetEntry handles showing a single file, editable or otherwise.
func GetEntry(w http.ResponseWriter, r *http.Request, title string, filename string, editable bool) {
entry, err := files.Read(filename)
if err != nil {
if editable && errors.Is(err, os.ErrNotExist) {
entry = []byte("")
} else {
slog.Error("error reading entry file", "error", err, "file", filename)
InternalError(w, r)
return
}
}
if editable {
err = templates.Edit.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)})
} else {
err = templates.View.ExecuteTemplate(w, "base", Entry{Title: title, Content: string(entry)})
}
if err != nil {
InternalError(w, r)
return
}
}
// PostEntry saves value of "text" HTML form component to a file and redirects back to Referer if present.
func PostEntry(filename string, w http.ResponseWriter, r *http.Request) {
err := files.Save(filename, []byte(r.FormValue("text")))
if err != nil {
slog.Error("error saving file", "error", err, "file", filename)
}
if r.Referer() != "" {
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusFound)
return
}
}

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

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

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

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

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

@ -0,0 +1,54 @@
package server
import (
"html/template"
"net/http"
"net/url"
"git.a71.su/Andrew71/hibiscus-txt/internal/files"
"git.a71.su/Andrew71/hibiscus-txt/internal/lang"
"github.com/go-chi/chi/v5"
)
// GetNotes calls GetEntries for all notes.
func GetNotes(w http.ResponseWriter, r *http.Request) {
// This is suboptimal, but will do...
description := template.HTML(
"<a href=\"#\" onclick='newNote(\"" + template.HTMLEscapeString(lang.Translate("prompt.notes")) + "\")'>" + template.HTMLEscapeString(lang.Translate("button.notes")) + "</a>" +
" <noscript>(" + template.HTMLEscapeString(lang.Translate("noscript.notes")) + ")</noscript>")
GetEntries(w, r, lang.Translate("title.notes"), description, "notes", func(files []string) []Entry {
var filesFormatted []Entry
for _, v := range files {
// titleString := strings.Replace(v, "-", " ", -1) // This would be cool, but what if I need a hyphen?
filesFormatted = append(filesFormatted, Entry{Title: v, Link: "notes/" + v})
}
return filesFormatted
})
}
// GetNote calls GetEntry for a note.
func GetNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("note not specified")))
return
}
// Handle non-latin note names
if decodedNote, err := url.QueryUnescape(noteString); err == nil {
noteString = decodedNote
}
GetEntry(w, r, noteString, files.DataFile("notes/"+noteString), true)
}
// PostNote calls PostEntry for a note.
func PostNote(w http.ResponseWriter, r *http.Request) {
noteString := chi.URLParam(r, "note")
if noteString == "" {
w.WriteHeader(http.StatusBadRequest)
HandleWrite(w.Write([]byte("note not specified")))
return
}
PostEntry(files.DataFile("notes/"+noteString), w, r)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

View 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); }
}

View 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)
}

View 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"
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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))
}