package main
import (
"cmp"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
)
var log = slog.New(slog.NewJSONHandler(os.Stdout, nil))
func hgo(w http.ResponseWriter, r *http.Request, loc string) {
http.Redirect(w, r, loc, http.StatusSeeOther)
}
const favi = "AAABAAEAEBACAAEAAQCwAAAAFgAAACgAAAAQAAAAIAAAAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA"
const schema = `
CREATE TABLE poem(
id INTEGER PRIMARY KEY,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
title TEXT NOT NULL UNIQUE,
verses TEXT NOT NULL
);
CREATE TRIGGER IF NOT EXISTS poem_upd_trig AFTER UPDATE ON poem
BEGIN update poem SET updated = CURRENT_TIMESTAMP WHERE id = NEW.id; END;
CREATE VIRTUAL TABLE IF NOT EXISTS poem_idx_fts5 USING fts5(
id UNINDEXED,
title,
verses,
content='poem',
content_rowid='id',
tokenize='trigram'
);
-- Triggers to keep the FTS index up to date.
CREATE TRIGGER IF NOT EXISTS poem_ai AFTER INSERT ON poem BEGIN
INSERT INTO poem_idx_fts5(rowid, title, verses)
VALUES (new.id, new.title, new.verses);
END;
CREATE TRIGGER IF NOT EXISTS poem_ad AFTER DELETE ON poem BEGIN
INSERT INTO poem_idx_fts5(poem_idx_fts5, rowid, title, verses)
VALUES ('delete', old.id, old.title, old.verses);
END;
CREATE TRIGGER IF NOT EXISTS poem_au AFTER UPDATE ON poem BEGIN
INSERT INTO poem_idx_fts5(poem_idx_fts5, rowid, title, verses)
VALUES ('delete', old.id, old.title, old.verses);
INSERT INTO poem_idx_fts5(rowid, title, verses)
VALUES (new.id, new.title, new.verses);
END;
`
var cReader = os.Getenv("POEMY_READER")
var cWriter = os.Getenv("POEMY_WRITER")
var cDb = cmp.Or(os.Getenv("POEMY_DB"), os.Getenv("HOME")+"/poemy.db")
var cAddrPort = cmp.Or(os.Getenv("POEMY_ADDRPORT"), "0.0.0.0:8080")
var cSessionLength = unwrap(strconv.Atoi(os.Getenv("POEMY_SESSION_LENGTH")))
func die(err error, msg string) {
if err == nil {
return
}
log.Error(msg, "err", err)
os.Exit(1)
}
func unwrap[T any](t T, err error) T {
die(err, "bad value")
return t
}
type user struct {
isWriter bool
}
func getUser(r *http.Request) (*user, error) {
cookie, err := r.Cookie("Session")
if err != nil {
return nil, err
}
switch cookie.Value {
case cWriter:
return &user{isWriter: true}, nil
case cReader:
return &user{isWriter: false}, nil
}
return nil, nil
}
func mustUser(w http.ResponseWriter, u *user, err error) (*user, bool) {
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return nil, false
}
if u != nil {
return u, true
}
w.WriteHeader(http.StatusUnauthorized)
return nil, false
}
func mustAdmin(w http.ResponseWriter, u *user, err error) (*user, bool) {
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return nil, false
}
if u != nil {
return u, true
}
w.WriteHeader(http.StatusUnauthorized)
return nil, false
}
func main() {
if cReader == "" {
die(errors.New("empty reader"), "")
}
if cWriter == "" {
die(errors.New("empty writer"), "")
}
db := unwrap(sql.Open("sqlite", cDb))
row := db.QueryRow(`SELECT COUNT(*) FROM poem;`)
var tmpCount int
err := row.Scan(&tmpCount)
if err != nil {
if strings.Contains(err.Error(), "no such table") {
unwrap(db.Exec(schema))
} else {
die(err, "could not check for existence")
}
}
http.Handle("GET /favicon.ico", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
favi, err := base64.StdEncoding.DecodeString(favi)
if err != nil {
w.Write([]byte{})
} else {
w.Write(favi)
}
}))
http.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r)
if err != nil || user == nil {
w.Write([]byte(`<!doctype html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Poemy - enter key</title>
<style>
@media(prefers-color-scheme:dark){body{color:white;background:black;}}
*, *::before, *::after { box-sizing: border-box; }
.nav{display:flex;margin-bottom:40px;}.nav a{padding:20px;}
</style>
</head>
<body>
<h1>Enter key</h1>
<form action="/login" method="POST"><label for="key">enter key</label><input required type="password" id="key" name="key"></input><input type="submit" value="log in"></input></form>
</body></html>`))
return
}
rows, err := db.Query(`SELECT id, title FROM poem;`)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
var resp strings.Builder
resp.WriteString(`
<!doctype html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Poemy - poems</title>
<style>
@media(prefers-color-scheme:dark){body{color:white;background:black;}}
*, *::before, *::after { box-sizing: border-box; }
.nav{display:flex;margin-bottom:40px;}.nav a{padding:20px;}
.poem-list {
margin:0;
padding:0;
}
.poem-list .title {
flex-grow: 1;
text-align: left;
}
.poem-list li {
display: flex;
align-items: center;
text-align: center;
margin-bottom: 15px;
}
.poem-list li:last-child {
margin-bottom: 0;
}
.poem-list a {
padding: 13px 20px;
flex-shrink: 0;
}
</style>
</head>
<body style="max-width: 400px; margin: 0 auto;">
<div class="nav">
<a href="/logout">log out</a>
`)
if user.isWriter {
resp.WriteString(`<a href="/new">new poem</a>`)
}
resp.WriteString(`</div>`)
resp.WriteString(`
<h1>Poems</h1>
<ul class="poem-list">
`)
for rows.Next() {
var id int
var title string
err = rows.Scan(&id, &title)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
var li string
if user.isWriter {
li = fmt.Sprintf(`<li><span class="title">%s</span> <a href="/%d">read</a><a href="/%d/deleteconfirm">delete</a></li>`, title, id, id)
} else {
li = fmt.Sprintf(`<li><span class="title">%s</span> <a href="/%d">read</a></li>`, title, id)
}
resp.WriteString(li)
}
if err := rows.Err(); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Write([]byte(resp.String()))
}))
http.Handle("POST /login", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
}
key := r.Form.Get("key")
switch key {
case cWriter, cReader:
SetSession(w.Header(), key)
hgo(w, r, "/")
default:
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("bad login value"))
return
}
}))
http.Handle("GET /logout", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Set-Cookie", DelSessionCookie)
hgo(w, r, "/")
}))
http.Handle("GET /{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r)
if _, ok := mustUser(w, user, err); !ok {
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
row := db.QueryRow(`SELECT title, verses, created, updated FROM poem WHERE id = $1;`, id)
var title string
var verses string
var created time.Time
var updated time.Time
err = row.Scan(&title, &verses, &created, &updated)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
var resp strings.Builder
header := fmt.Sprintf(`
<!doctype html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%q</title>
<style>
@media(prefers-color-scheme:dark){body{color:white;background:black;}}
*, *::before, *::after { box-sizing: border-box; }
.nav{display:flex;margin-bottom:40px;}.nav a{padding:20px;}
</style>
</head>
<body style="max-width: 400px; margin: 0 auto;">
<div class="nav">
<a href="/">home</a>
<a href="/logout">log out</a>
`, title)
resp.WriteString(header)
if user.isWriter {
var writerLinks = fmt.Sprintf(` <a href="/%d/edit">edit poem</a> <a href="/%d/deleteconfirm">delete poem</a>`, id, id)
resp.WriteString(writerLinks)
}
resp.WriteString("</div>")
resp.WriteString("<h1>")
resp.WriteString(title)
resp.WriteString("</h1>")
for stanza := range strings.SplitSeq(verses, "\n\n") {
resp.WriteString(`<p style="white-space:pre;">`)
resp.WriteString(strings.TrimSpace(stanza))
resp.WriteString("</p>")
}
resp.WriteString("</body></html>")
w.Write([]byte(resp.String()))
}))
http.Handle("GET /{id}/edit", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r)
if _, ok := mustAdmin(w, user, err); !ok {
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
row := db.QueryRow("SELECT title, verses FROM poem WHERE id = $1", id)
var title, verses string
err = row.Scan(&title, &verses)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
var resp = fmt.Sprintf(`<!doctype html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Poemy - write</title>
<style>
@media(prefers-color-scheme:dark){body{color:white;background:black;}}
*, *::before, *::after { box-sizing: border-box; }
.nav{display:flex;margin-bottom:40px;}.nav a{padding:20px;}
</style>
</head>
<body style="max-width:400px;margin:0 auto;width:100vw;">
<div class="nav">
<a href="/">home</a>
<a href="/logout">log out</a>
</div>
<h1>Edit poem</h1>
<form action="/%d/edit" method="POST" style="display: flex; flex-direction: column;">
<div>
<label for="title">title</label><input id="title" required name="title" style="display:block;width:100%%;margin-bottom:40px;" value="%s"></input>
</div>
<div>
<label for="verses">verses</label><textarea rows="15" id="verses" required name="verses" style="display:block;width:100%%;margin-bottom:40px;">%s</textarea>
</div>
<input type="submit" value="Save" style="display:block;width:100%%;"></input>
</form>
</body></html>`, id, title, verses)
w.Write([]byte(resp))
}))
http.Handle("POST /{id}/edit", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r)
if _, ok := mustAdmin(w, user, err); !ok {
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
err = r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
title := strings.ReplaceAll(r.Form.Get("title"), "\r", "")
verses := strings.ReplaceAll(r.Form.Get("verses"), "\r", "")
if title == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("title required"))
return
}
if verses == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("verses required"))
return
}
_, err = db.Exec(`UPDATE poem set title=$1, verses=$2 WHERE id=$3;`, title, verses, id)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
hgo(w, r, "/"+strconv.Itoa(id))
}))
http.Handle("GET /{id}/delete", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r)
if _, ok := mustAdmin(w, user, err); !ok {
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
_, err = db.Exec("DELETE FROM poem WHERE id = $1", id)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
hgo(w, r, "/")
}))
http.Handle("GET /{id}/deleteconfirm", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r)
if _, ok := mustAdmin(w, user, err); !ok {
return
}
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
row = db.QueryRow("SELECT title FROM poem WHERE id = $1", id)
var title string
err = row.Scan(&title)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
resp := fmt.Sprintf(`<!doctype html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete - %s</title>
<style>
@media(prefers-color-scheme:dark){body{color:white;background:black;}}
*, *::before, *::after { box-sizing: border-box; }
.nav{display:flex;margin-bottom:40px;}.nav a{padding:20px;}
.actions a{padding:20px;}
</style>
</head>
<body>
<div class="nav">
<a href="/">home</a>
<a href="/logout">log out</a>
</div>
<h1>Delete confirm - %q</h1>
<p>Are you sure you want to delete %q? This action can not be undone.</p>
<div class="actions">
<a href="/%d/delete">yes</a>
<a href="/%d">no</a>
</div>
</body></html>`, title, title, title, id, id)
w.Write([]byte(resp))
}))
http.Handle("GET /new", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r)
if _, ok := mustAdmin(w, user, err); !ok {
return
}
w.Write([]byte(`<!doctype html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Poemy - write</title>
<style>
@media(prefers-color-scheme:dark){body{color:white;background:black;}}
*, *::before, *::after { box-sizing: border-box; }
.nav{display:flex;margin-bottom:40px;}.nav a{padding:20px;}
</style>
</head>
<body style="max-width:400px;margin:0 auto;width:100vw;">
<div class="nav">
<a href="/">home</a>
<a href="/logout">log out</a>
</div>
<h1>Enter poem</h1>
<form action="/new" method="POST" style="display: flex; flex-direction: column;">
<div>
<label for="title">title</label><input id="title" required name="title" style="display:block;width:100%;margin-bottom:40px;"></input>
</div>
<div>
<label for="verses">verses</label><textarea rows="15" id="verses" required name="verses" style="display:block;width:100%;margin-bottom:40px;"></textarea>
</div>
<input type="submit" value="Save" style="display:block;width:100%;"></input>
</form>
</body></html>`))
}))
http.Handle("POST /new", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := getUser(r)
if _, ok := mustAdmin(w, user, err); !ok {
return
}
err = r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
title := strings.ReplaceAll(r.Form.Get("title"), "\r", "")
verses := strings.ReplaceAll(r.Form.Get("verses"), "\r", "")
if title == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("title required"))
return
}
if verses == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("verses required"))
return
}
row := db.QueryRow(`INSERT INTO poem (title, verses) VALUES ($1, $2) RETURNING id;`, title, verses)
var id int
err = row.Scan(&id)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
hgo(w, r, "/recipes/"+strconv.Itoa(id))
}))
log.Info("listening", "addr_port", cAddrPort)
http.ListenAndServe(cAddrPort, nil)
}
const DelSessionCookie = "Session=; Path=/; Max-Age=0; HttpOnly; Secure; Partitioned; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
func SetSession(h http.Header, token string) {
cookieVal := fmt.Sprintf(
"Session=%s; Path=/; Max-Age=%d; HttpOnly; Secure; Partitioned; SameSite=Lax",
token,
cSessionLength,
)
h.Set("Set-Cookie", cookieVal)
}