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