claude go reimplement

This commit is contained in:
tyler committed 2026-10-26 20:30:43 -07:00
1 parent 4bcb924158
commit f523c79618
83 files changed
+3714 -3454

No files matched your search

+430
View File
@@ -0,0 +1,430 @@
package kjv
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strings"
"time"
)
// App is the whole web application. Everything it serves comes from the
// companion database (see assets.go), loaded once at startup.
type App struct {
store *Store
res *Resolver
speller *Speller
started time.Time
dictionary map[string][]string // 1828 Webster
variants map[string][]string
synonyms map[string][]string
concepts map[string]concept
bookDescs map[string]string
chapSums map[string][]string
served map[string]servedAsset // versioned static files: /styles/page.css ...
pages map[string]string // simple html pages, asset urls versioned
htmlResponse, htmlNavbar, htmlChapterNav string
cssNavbar, cssCollapsible string
favicon []byte
indexHTML string
sermonDB *sql.DB
secretKey string
semantic string // host:port of the optional semantic search service
}
type concept struct {
Query string `json:"query"`
Explanation string `json:"explanation"`
SearchTerms []string `json:"search_terms"`
}
type servedAsset struct {
body []byte
mime string
version string
etag string
}
var servedPaths = map[string]string{ // url -> asset path
"/styles/page.css": "styles/page.css",
"/js/collapsible.js": "js/collapsible.js",
"/js/shortcode.js": "js/shortcode.js",
"/js/verse_menu.js": "js/verse_menu.js",
"/js/book_suggest.js": "js/book_suggest.js",
}
func NewApp(store *Store) (*App, error) {
assets, err := store.LoadAssets()
if err != nil {
return nil, err
}
need := func(p string) ([]byte, error) {
b, ok := assets[p]
if !ok {
return nil, fmt.Errorf("asset %s is missing from the database (run: kjv-api import-assets <source dir>)", p)
}
return b, nil
}
a := &App{store: store, res: NewResolver(store), started: time.Now(), pages: map[string]string{}, served: map[string]servedAsset{}}
loadJSON := func(p string, into any) error {
b, err := need(p)
if err != nil {
return err
}
if err := json.Unmarshal(b, into); err != nil {
return fmt.Errorf("%s: %w", p, err)
}
return nil
}
if err := loadJSON("data/1828_Webster_KJV.json", &a.dictionary); err != nil {
return nil, err
}
if err := loadJSON("data/book_descriptions.json", &a.bookDescs); err != nil {
return nil, err
}
if err := loadJSON("data/chapter_summaries.json", &a.chapSums); err != nil {
return nil, err
}
var groups [][]string
if err := loadJSON("data/grammatical_variants.json", &groups); err != nil {
return nil, err
}
a.variants = groupMap(groups)
if err := loadJSON("data/theological_synonyms.json", &groups); err != nil {
return nil, err
}
a.synonyms = groupMap(groups)
var concepts []concept
if err := loadJSON("data/concept_mappings.json", &concepts); err != nil {
return nil, err
}
a.concepts = map[string]concept{}
for _, c := range concepts {
a.concepts[strings.ToLower(c.Query)] = c
}
dic, err := need("data/kjv.dic")
if err != nil {
return nil, err
}
aff, err := need("data/kjv.aff")
if err != nil {
return nil, err
}
a.speller = NewSpeller(dic, aff)
// static assets, each URL carrying a hash of its own contents
for u, p := range servedPaths {
b, err := need(p)
if err != nil {
return nil, err
}
if p == "js/book_suggest.js" {
names, _ := json.Marshal(store.bookNames)
b = bytes.ReplaceAll(b, []byte("__BOOKS__"), names)
}
sum := sha256.Sum256(b)
mime := assetMIME[p[strings.LastIndexByte(p, '.'):]]
a.served[u] = servedAsset{b, mime, fmt.Sprintf("%x", sum[:4]), fmt.Sprintf(`"%x"`, sum[:8])}
}
text := func(p string) (string, error) { b, err := need(p); return string(b), err }
if a.htmlResponse, err = text("html/response.html"); err != nil {
return nil, err
}
if a.htmlChapterNav, err = text("html/chapter_nav.html"); err != nil {
return nil, err
}
if a.cssNavbar, err = text("styles/header.css"); err != nil {
return nil, err
}
if a.cssCollapsible, err = text("styles/collapsible.css"); err != nil {
return nil, err
}
nav, err := text("html/navbar.html")
if err != nil {
return nil, err
}
a.htmlNavbar = a.loadHTML(nav)
if a.favicon, err = need("static/favicon.svg"); err != nil {
return nil, err
}
for _, p := range []string{"html/find.html", "html/show.html", "html/search.html", "html/seq.html", "html/dictionary.html",
"html/saved.html", "html/plan.html", "html/heaven.html", "kjv_fe.html"} {
t, err := text(p)
if err != nil {
return nil, err
}
a.pages[p] = a.loadHTML(t)
}
index, err := text("html/index.html")
if err != nil {
return nil, err
}
a.indexHTML = a.renderIndex(a.loadHTML(index))
return a, nil
}
func groupMap(groups [][]string) map[string][]string {
m := map[string][]string{}
for _, g := range groups {
for _, w := range g {
var others []string
for _, o := range g {
if o != w {
others = append(others, o)
}
}
m[w] = others
}
}
return m
}
var assetRefRE = regexp.MustCompile(`(/(?:js|styles)/[\w.\-]+)\?v=__V__`)
// loadHTML fills each '/js/x.js?v=__V__' with that file's own version.
func (a *App) loadHTML(s string) string {
return assetRefRE.ReplaceAllStringFunc(s, func(m string) string {
u := assetRefRE.FindStringSubmatch(m)[1]
if as, ok := a.served[u]; ok {
return u + "?v=" + as.version
}
return m
})
}
func (a *App) assetURL(u string) string { return u + "?v=" + a.served[u].version }
var indexLoopRE = regexp.MustCompile(`(?s)\{% for category, books in testament_data\.(OT|NT)\.items\(\) %\}.*?\n \{% endfor %\}`)
// renderIndex fills the book lists of the home page.
func (a *App) renderIndex(tmpl string) string {
esc := html.EscapeString
return indexLoopRE.ReplaceAllStringFunc(tmpl, func(m string) string {
testament := indexLoopRE.FindStringSubmatch(m)[1]
var order []string
byCat := map[string][]BookInfo{}
for _, b := range a.store.bookInfo {
if b.OTNT != testament {
continue
}
if _, ok := byCat[b.Category]; !ok {
order = append(order, b.Category)
}
byCat[b.Category] = append(byCat[b.Category], b)
}
var sb strings.Builder
for _, cat := range order {
fmt.Fprintf(&sb, "\n <h3 class=\"category-header\">%s</h3>\n <div class=\"book-grid\">", esc(cat))
for _, b := range byCat[cat] {
fmt.Fprintf(&sb, "\n<div><button class=\"book-button collapsible\" data-book=\"%s\">%s</button>\n<div class=\"chapter-grid content\">", esc(b.Title), esc(b.Title))
if about := a.bookDescs[b.Title]; about != "" {
fmt.Fprintf(&sb, "<p class=\"book-about\">%s</p>", esc(about))
}
sums := a.chapSums[b.Title]
for c := 1; c <= b.Chapters; c++ {
fmt.Fprintf(&sb, "\n<a href=\"/show?kw=%s %d&view=rich\" class=\"chapter-link\"><span class=\"chapter-num\">%d</span>", esc(b.Title), c, c)
if len(sums) >= c {
fmt.Fprintf(&sb, "<span class=\"chapter-sum\">%s</span>", esc(sums[c-1]))
}
sb.WriteString("</a>")
}
sb.WriteString("\n</div></div>")
}
sb.WriteString("\n </div>")
}
return sb.String()
})
}
// ---------------------------------------------------------------------------
// responses
type Resp struct {
Body string
Type string
Status int
Header map[string]string
}
func htmlR(s string) Resp { return Resp{Body: s, Type: "text/html; charset=utf-8"} }
func jsonR(v any) Resp { return jsonStatus(v, 0) }
func jsonStatus(v any, status int) Resp {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode(v)
return Resp{Body: buf.String(), Type: "application/json", Status: status}
}
func errJSON(status int, msg string) Resp { return jsonStatus(map[string]string{"error": msg}, status) }
func (r Resp) write(w http.ResponseWriter) {
h := w.Header()
h.Set("Content-Type", r.Type)
if strings.HasPrefix(r.Type, "text/html") && r.Header["Cache-Control"] == "" {
h.Set("Cache-Control", "no-cache")
}
for k, v := range r.Header {
h.Set(k, v)
}
if r.Status != 0 {
w.WriteHeader(r.Status)
}
w.Write([]byte(r.Body))
}
func (a *App) staticPage(p string) Resp { return htmlR(a.pages[p]) }
// staticHTMLDoc wraps a small html snippet in the standard page shell.
func (a *App) staticHTMLDoc(title, body string) string {
return "<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'>" +
"<meta name='viewport' content='width=device-width, initial-scale=1.0'>" +
"<title>" + title + " - KJV API</title>" +
"<link rel='stylesheet' href='" + a.assetURL("/styles/page.css") + "'></head><body>" +
"<header><a href='/'>Home</a> <a href='/find'>Quick Search</a></header>" +
body + "</body></html>"
}
// ---------------------------------------------------------------------------
// verse rendering
func (a *App) responseRich(v Verse) string {
passage := fmt.Sprintf("%s %d:%d", a.store.BookName(v.Book), v.Chapter, v.Verse)
return strings.NewReplacer(
"{chapter}", passage[:strings.LastIndexByte(passage, ':')],
"{passage}", passage,
"{text}", v.Text,
"{verse_id}", fmt.Sprint(v.ID),
).Replace(a.htmlResponse)
}
func chapterFindLink(name string) string { return "/find?kw=" + url.PathEscape(name) + "&view=rich" }
type chapterNav struct{ current, prev, next string }
func (a *App) chapterName(key int64) string {
return fmt.Sprintf("%s %d", a.store.BookName(int(key/1000)), key%1000)
}
func (a *App) nextPrevChapter(verseID int64) chapterNav {
key := verseID / 1000
n := len(a.store.chapterList)
i := sort.Search(n, func(i int) bool { return a.store.chapterList[i] >= key })
return chapterNav{
current: a.chapterName(key),
prev: a.chapterName(a.store.chapterList[(i-1+n)%n]),
next: a.chapterName(a.store.chapterList[(i+1)%n]),
}
}
// renderNavbar opens the rich html document: <head>, site header and the
// chapter bar. The caller adds the body content after it.
func (a *App) renderNavbar(query string, nav *chapterNav, barHTML string) string {
esc := html.EscapeString
title := strings.TrimSpace(query)
if title == "" && nav != nil {
title = nav.current
}
if title == "" {
title = "Scripture"
}
chapterHTML := barHTML
if nav != nil {
chapterHTML = strings.NewReplacer(
"{current_chapter_name}", esc(nav.current),
"{prev_chapter_name}", esc(nav.prev),
"{prev_chapter_link}", esc("/show?kw="+nav.prev+"&view=rich"),
"{next_chapter_name}", esc(nav.next),
"{next_chapter_link}", esc("/show?kw="+nav.next+"&view=rich"),
"{current_chapter_link}", esc(chapterFindLink(nav.current)),
).Replace(a.htmlChapterNav)
}
return strings.NewReplacer(
"{title}", esc(title), "{css}", a.cssNavbar, "{query}", esc(query), "{chapter_nav}", chapterHTML,
).Replace(a.htmlNavbar)
}
type rendered struct {
text string // plain and rich
list []map[string]any
isJSON bool
}
func (r rendered) resp() Resp {
if r.isJSON {
return jsonR(r.list)
}
return htmlR(r.text)
}
// render lays out verses as view=plain, view=rich or (anything else) json.
func (a *App) render(verses []Verse, view, kw string) rendered {
switch view {
case "plain":
parts := make([]string, len(verses))
for i, v := range verses {
parts[i] = fmt.Sprintf("%s %d:%d %s\n", a.store.BookName(v.Book), v.Chapter, v.Verse, v.Text)
}
return rendered{text: strings.Join(parts, "<br>")}
case "rich":
if len(verses) == 0 {
lg.Error("Malformed query: %s", kw)
return rendered{text: "Malformed Query - if you believe this to be in error (your query should resolve), email tyler@dinsmoor.us"}
}
// Prev/next chapter links only make sense when reading a single chapter,
// not for a keyword search that spans books.
var nav *chapterNav
first := verses[0].ID / 1000
single := true
for _, v := range verses {
if v.ID/1000 != first {
single = false
break
}
}
if single {
n := a.nextPrevChapter(verses[0].ID)
nav = &n
}
var sb strings.Builder
sb.WriteString(a.renderNavbar(kw, nav, ""))
sb.WriteString("<div class='responses'>")
fmt.Fprintf(&sb, "<div class='results-count'>%d Results</div>", len(verses))
for _, v := range verses {
sb.WriteString(a.responseRich(v))
}
sb.WriteString("</div>")
return rendered{text: sb.String()}
}
list := make([]map[string]any, len(verses))
for i, v := range verses {
list[i] = map[string]any{"bookname": a.store.BookName(v.Book), "chapter": v.Chapter, "verse": v.Verse, "text": v.Text}
}
return rendered{list: list, isJSON: true}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func itoa64(n int64) string { return fmt.Sprint(n) }
func urlQuery(s string) string { return url.QueryEscape(s) }
func htmlEsc(s string) string { return html.EscapeString(s) }
+113
View File
@@ -0,0 +1,113 @@
package kjv
import (
"database/sql"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
// Everything the program serves or reads besides the Bible itself (pages,
// scripts, styles, the 1828 dictionary, word lists) lives in the assets table
// of the companion database, so the program and the database are the only two
// files that need to be distributed.
var assetMIME = map[string]string{
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json",
".svg": "image/svg+xml",
".dic": "text/plain; charset=utf-8",
".aff": "text/plain; charset=utf-8",
}
func isAssetFile(rel string) bool {
dir := path.Dir(rel)
switch {
case rel == "kjv_fe.html":
return true
case dir == "html", dir == "js", dir == "static":
return true
case dir == "styles":
return strings.HasSuffix(rel, ".css")
case dir == "data":
ext := path.Ext(rel)
return ext == ".json" || ext == ".dic" || ext == ".aff"
}
return false
}
// ImportAssets replaces the assets table with the files of a source checkout.
func ImportAssets(dbPath, srcDir string) error {
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
if err != nil {
return err
}
defer db.Close()
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, stmt := range []string{
"CREATE TABLE IF NOT EXISTS assets (path TEXT PRIMARY KEY, mime TEXT NOT NULL, body BLOB NOT NULL)",
"DELETE FROM assets",
} {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
n := 0
err = filepath.WalkDir(srcDir, func(p string, d fs.DirEntry, err error) error {
if err != nil || !d.Type().IsRegular() { // no directories, and never follow a symlink out of the tree
return err
}
rel, _ := filepath.Rel(srcDir, p)
rel = filepath.ToSlash(rel)
if !isAssetFile(rel) {
return nil
}
body, err := os.ReadFile(p)
if err != nil {
return err
}
mime := assetMIME[path.Ext(rel)]
if mime == "" {
mime = "application/octet-stream"
}
n++
_, err = tx.Exec("INSERT INTO assets (path, mime, body) VALUES (?, ?, ?)", rel, mime, body)
return err
})
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("no assets found under %s (expected html/, js/, styles/, data/*.json ...)", srcDir)
}
fmt.Printf("imported %d assets into %s\n", n, dbPath)
return tx.Commit()
}
// LoadAssets reads every asset into memory.
func (s *Store) LoadAssets() (map[string][]byte, error) {
rows, err := s.db.Query("SELECT path, body FROM assets")
if err != nil {
return nil, fmt.Errorf("no assets table (run: kjv-api import-assets <source dir>): %w", err)
}
defer rows.Close()
out := map[string][]byte{}
for rows.Next() {
var p string
var b []byte
if err := rows.Scan(&p, &b); err != nil {
return nil, err
}
out[p] = b
}
return out, rows.Err()
}
+76
View File
@@ -0,0 +1,76 @@
package kjv
// Ratcliff/Obershelp string similarity (the ratio of matching characters) and
// a closest match lookup, which the fuzzy book name matching relies on.
type matchBlock struct{ a, b, size int }
func longestMatch(a, b []rune, alo, ahi, blo, bhi int) matchBlock {
besti, bestj, bestsize := alo, blo, 0
prev := make([]int, len(b)+1)
cur := make([]int, len(b)+1)
for i := alo; i < ahi; i++ {
for j := blo; j < bhi; j++ {
if a[i] != b[j] {
cur[j+1] = 0
continue
}
k := prev[j] + 1
cur[j+1] = k
if k > bestsize {
besti, bestj, bestsize = i-k+1, j-k+1, k
}
}
prev, cur = cur, prev
for j := range cur {
cur[j] = 0
}
}
return matchBlock{besti, bestj, bestsize}
}
func matchedChars(a, b []rune) int {
type span struct{ alo, ahi, blo, bhi int }
queue := []span{{0, len(a), 0, len(b)}}
total := 0
for len(queue) > 0 {
q := queue[len(queue)-1]
queue = queue[:len(queue)-1]
m := longestMatch(a, b, q.alo, q.ahi, q.blo, q.bhi)
if m.size == 0 {
continue
}
total += m.size
if q.alo < m.a && q.blo < m.b {
queue = append(queue, span{q.alo, m.a, q.blo, m.b})
}
if m.a+m.size < q.ahi && m.b+m.size < q.bhi {
queue = append(queue, span{m.a + m.size, q.ahi, m.b + m.size, q.bhi})
}
}
return total
}
func similarity(a, b string) float64 {
ra, rb := []rune(a), []rune(b)
if len(ra)+len(rb) == 0 {
return 1
}
return 2.0 * float64(matchedChars(ra, rb)) / float64(len(ra)+len(rb))
}
// closestMatch is the best scoring possibility with a ratio of at least cutoff.
// Ties on score go to the lexicographically larger possibility.
func closestMatch(word string, possibilities []string, cutoff float64) (string, bool) {
best, bestScore, found := "", 0.0, false
for _, p := range possibilities {
score := similarity(p, word) // difflib compares possibility (a) against word (b)
if score < cutoff {
continue
}
if !found || score > bestScore || (score == bestScore && p > best) {
best, bestScore, found = p, score, true
}
}
return best, found
}
+198
View File
@@ -0,0 +1,198 @@
package kjv
import (
"database/sql"
"fmt"
"os"
"strings"
"sync"
"syscall"
"time"
"unicode/utf8"
)
// DBLog writes log lines into the logs table of the companion database. Lines
// are queued and inserted in batches so requests never wait on a write. Until
// a database is attached (and if it turns out to be read-only) lines go to stderr.
type DBLog struct {
mu sync.Mutex
db *sql.DB
ch chan logLine
done chan struct{}
started bool
retain int
}
type logLine struct {
ts, level, msg string
}
var lg = &DBLog{ch: make(chan logLine, 4096), done: make(chan struct{})}
const (
logTimeFmt = "2006-01-02 15:04:05.000"
maxLogLine = 400 // request URLs and queries come from strangers
)
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) { // don't cut a character in half
n--
}
return s[:n] + "..."
}
func (l *DBLog) Attach(db *sql.DB, retainDays int) {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, level TEXT NOT NULL, msg TEXT NOT NULL)`); err != nil {
fmt.Fprintln(os.Stderr, "WARN: cannot write logs to the database, logging to stderr:", err)
return
}
l.retain = retainDays
l.prune(db)
l.mu.Lock()
l.db, l.started = db, true
l.mu.Unlock()
go l.run()
}
func (l *DBLog) prune(db *sql.DB) {
db.Exec("DELETE FROM logs WHERE ts < ?", time.Now().AddDate(0, 0, -l.retain).Format(logTimeFmt))
}
func (l *DBLog) run() {
tick := time.NewTicker(time.Second)
defer tick.Stop()
daily := time.NewTicker(6 * time.Hour)
defer daily.Stop()
var batch []logLine
flush := func() {
if len(batch) == 0 {
return
}
tx, err := l.db.Begin()
if err == nil {
for _, ln := range batch {
if _, err = tx.Exec("INSERT INTO logs (ts, level, msg) VALUES (?, ?, ?)", ln.ts, ln.level, ln.msg); err != nil {
break
}
}
if err == nil {
err = tx.Commit()
} else {
tx.Rollback()
}
}
if err != nil {
for _, ln := range batch {
fmt.Fprintf(os.Stderr, "%s %s %s\n", ln.ts, ln.level, ln.msg)
}
}
batch = batch[:0]
}
for {
select {
case ln := <-l.ch:
batch = append(batch, ln)
if len(batch) >= 200 {
flush()
}
case <-tick.C:
flush()
case <-daily.C:
l.prune(l.db)
case <-l.done:
for {
select {
case ln := <-l.ch:
batch = append(batch, ln)
default:
flush()
l.done <- struct{}{}
return
}
}
}
}
}
// Close flushes queued lines.
func (l *DBLog) Close() {
l.mu.Lock()
started := l.started
l.mu.Unlock()
if !started {
return
}
l.done <- struct{}{}
<-l.done
}
// Under systemd (stderr is the journal) or with KJV_LOG_STDERR=1 every line is
// also written to stderr, where the journal collects it. The "<n>" prefix is
// the syslog priority journald understands; it adds its own timestamps.
var teeStderr = stderrIsJournal() || os.Getenv("KJV_LOG_STDERR") == "1"
var journalPriority = map[string]int{"ERROR": 3, "WARN": 4, "INFO": 6}
// stderrIsJournal: JOURNAL_STREAM is inherited by everything a systemd
// started desktop session runs, terminals included, so it only counts when it
// names the device:inode stderr really is (as systemd.exec(5) advises).
func stderrIsJournal() bool {
want := os.Getenv("JOURNAL_STREAM")
if want == "" {
return false
}
fi, err := os.Stderr.Stat()
if err != nil {
return false
}
st, ok := fi.Sys().(*syscall.Stat_t)
return ok && fmt.Sprintf("%d:%d", st.Dev, st.Ino) == want
}
func (l *DBLog) log(level, format string, args ...any) {
ln := logLine{time.Now().Format(logTimeFmt), level, truncate(fmt.Sprintf(format, args...), maxLogLine)}
if teeStderr {
fmt.Fprintf(os.Stderr, "<%d>%s\n", journalPriority[level], strings.ReplaceAll(ln.msg, "\n", " "))
}
l.mu.Lock()
started := l.started
l.mu.Unlock()
if started {
select {
case l.ch <- ln:
return
default: // queue full: don't block a request
}
}
if !teeStderr { // not in the database and not already on stderr
fmt.Fprintf(os.Stderr, "%s %s %s\n", ln.ts, ln.level, ln.msg)
}
}
func (l *DBLog) Info(format string, args ...any) { l.log("INFO", format, args...) }
func (l *DBLog) Warn(format string, args ...any) { l.log("WARN", format, args...) }
func (l *DBLog) Error(format string, args ...any) { l.log("ERROR", format, args...) }
// ShowLogs prints the newest n log lines (oldest first).
func ShowLogs(dbPath string, n int) error {
db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(5000)")
if err != nil {
return err
}
defer db.Close()
rows, err := db.Query("SELECT ts, level, msg FROM (SELECT * FROM logs ORDER BY id DESC LIMIT ?) ORDER BY id", n)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var ts, level, msg string
if err := rows.Scan(&ts, &level, &msg); err != nil {
return err
}
fmt.Printf("%s %-5s %s\n", ts, level, msg)
}
return rows.Err()
}
+86
View File
@@ -0,0 +1,86 @@
package kjv
// MT19937 seeded with init_by_array from one integer, and a choice made by
// rejection sampling over its bits, so the verse of the day for any given date
// never changes.
type mtRandom struct {
mt [624]uint32
idx int
}
func newMTRandom(seed uint32) *mtRandom {
r := &mtRandom{}
r.initGenrand(19650218)
key := []uint32{seed}
i, j := 1, 0
k := max(624, len(key))
for ; k > 0; k-- {
r.mt[i] = (r.mt[i] ^ ((r.mt[i-1] ^ (r.mt[i-1] >> 30)) * 1664525)) + key[j] + uint32(j)
i++
j++
if i >= 624 {
r.mt[0] = r.mt[623]
i = 1
}
if j >= len(key) {
j = 0
}
}
for k = 623; k > 0; k-- {
r.mt[i] = (r.mt[i] ^ ((r.mt[i-1] ^ (r.mt[i-1] >> 30)) * 1566083941)) - uint32(i)
i++
if i >= 624 {
r.mt[0] = r.mt[623]
i = 1
}
}
r.mt[0] = 0x80000000
r.idx = 624
return r
}
func (r *mtRandom) initGenrand(s uint32) {
r.mt[0] = s
for i := 1; i < 624; i++ {
r.mt[i] = 1812433253*(r.mt[i-1]^(r.mt[i-1]>>30)) + uint32(i)
}
r.idx = 624
}
func (r *mtRandom) next() uint32 {
if r.idx >= 624 {
for k := 0; k < 624; k++ {
y := (r.mt[k] & 0x80000000) | (r.mt[(k+1)%624] & 0x7fffffff)
v := r.mt[(k+397)%624] ^ (y >> 1)
if y&1 != 0 {
v ^= 0x9908b0df
}
r.mt[k] = v
}
r.idx = 0
}
y := r.mt[r.idx]
r.idx++
y ^= y >> 11
y ^= (y << 7) & 0x9d2c5680
y ^= (y << 15) & 0xefc60000
y ^= y >> 18
return y
}
// choiceIndex is random.choice's _randbelow(n) for n < 2**32.
func (r *mtRandom) choiceIndex(n int) int {
if n <= 0 {
return 0
}
k := 0
for v := n; v > 0; v >>= 1 {
k++
}
for {
if x := int(r.next() >> (32 - k)); x < n {
return x
}
}
}
+512
View File
@@ -0,0 +1,512 @@
package kjv
import (
"fmt"
"html"
"math/rand/v2"
"net/url"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
const (
maxSearchWords = 20
maxSearchLen = 200
)
const searchChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+^* "
// cleanInput keeps only the allowed characters.
func cleanInput(s, valid string) string {
return strings.Map(func(r rune) rune {
if strings.ContainsRune(valid, r) {
return r
}
return -1
}, s)
}
func searchClean(s string) string { return cleanInput(s, searchChars) }
// ---------------------------------------------------------------------------
// /show
func (a *App) show(kw, view string, all bool) (Resp, bool) {
resolved := a.res.Resolve(kw, true)
if resolved == nil {
return htmlR("Invalid Query"), false
}
var verses []Verse
for _, r := range resolved.Refs {
end := r.End
if all { // ?all= : from the given verse to the end of its chapter
end = r.Start/1000*1000 + maxChapterIDSpan
}
verses = append(verses, a.store.Range(r.Start, end)...)
}
return a.render(verses, view, kw).resp(), true
}
func viewOf(q url.Values, def string) string {
if q.Has("view") {
return q.Get("view")
}
return def
}
func (a *App) handleShow(q url.Values) Resp {
kw := q.Get("kw")
if kw == "" {
return a.staticPage("html/show.html")
}
resp, _ := a.show(kw, viewOf(q, "json"), q.Get("all") != "")
return resp
}
// ---------------------------------------------------------------------------
// /random and /votd
func (a *App) singleVerse(id int64, q url.Values, defView string) Resp {
return a.render(a.store.Range(id, 0), viewOf(q, defView), "").resp()
}
func (a *App) handleRandom(q url.Values, defView string) Resp {
id := a.store.verseIDs[rand.IntN(len(a.store.verseIDs))]
lg.Info("Random served: %d", id)
return a.singleVerse(id, q, defView)
}
// votdID is deterministic: the date seeds the generator, so everyone gets the same verse that day.
func (a *App) votdID(t time.Time) int64 {
seed, _ := strconv.Atoi(t.Format("20060102"))
r := newMTRandom(uint32(seed))
return a.store.verseIDs[r.choiceIndex(len(a.store.verseIDs))]
}
func (a *App) handleVotd(q url.Values, defView string) Resp {
lg.Info("VOTD served")
return a.singleVerse(a.votdID(time.Now()), q, defView)
}
// ---------------------------------------------------------------------------
// dictionary
func (a *App) defineRich(ref string, defs []string, simple bool) string {
head := ""
if !simple {
head = "<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'>" +
"<meta name='viewport' content='width=device-width, initial-scale=1'>" +
"<meta name='color-scheme' content='dark light'>" +
"<title>" + html.EscapeString(capitalize(ref)) + " - KJV API</title>" +
a.cssNavbar + "</head><body><div class='responses'>"
}
text := ""
for _, d := range defs {
text += d + "<br><br>"
}
out := head + strings.NewReplacer(
"{chapter}", strings.SplitN(ref, ":", 2)[0], "{passage}", capitalize(ref), "{text}", text, "{verse_id}", "",
).Replace(a.htmlResponse)
if !simple {
out += "</div></body></html>"
}
return out
}
func (a *App) handleDefine(q url.Values) Resp {
ref := strings.ToLower(strings.TrimSpace(q.Get("kw")))
if ref == "" {
return a.staticPage("html/dictionary.html")
}
defs, ok := a.dictionary[ref]
if !ok {
lg.Warn("WEBSTER MISSING: %s", ref)
return htmlR(html.EscapeString(ref) + " not found.")
}
switch q.Get("view") {
case "plain":
text := ""
for _, d := range defs {
text += d + "<br><br>"
}
return htmlR(capitalize(ref) + "<br><br>" + text + "\n")
case "rich":
return htmlR(a.defineRich(ref, defs, false))
}
return jsonR(map[string][]string{ref: defs})
}
var dictStopwords = map[string]bool{}
func init() {
for _, w := range strings.Fields(`the and that for him his her but not with are was were unto thou thee thy ye you all from
they them this have had shall will which into upon then`) {
dictStopwords[w] = true
}
}
// /defs?words=grace,loved : several definitions at once
func (a *App) handleDefs(q url.Values) Resp {
words := strings.Fields(strings.ReplaceAll(strings.ToLower(q.Get("words")), ",", " "))
if len(words) > 40 {
words = words[:40]
}
found := map[string][]string{}
for _, w := range words {
w = strings.Map(func(r rune) rune {
if unicode.IsLetter(r) {
return r
}
return -1
}, w)
if d, ok := a.dictionary[w]; ok && w != "" && !dictStopwords[w] {
found[w] = d
}
}
return jsonR(found)
}
// ---------------------------------------------------------------------------
// /search
// proximityScore: higher when the query words appear closer together.
func proximityScore(text string, words []string) float64 {
lower := strings.ToLower(text)
type hit struct{ pos, list int }
var hits []hit
seen := map[string]int{}
for _, w := range words {
if _, dup := seen[w]; dup {
continue
}
li := len(seen)
seen[w] = li
w = strings.ToLower(w)
n := 0
for at := 0; ; {
i := strings.Index(lower[at:], w)
if i < 0 {
break
}
hits = append(hits, hit{at + i, li})
n++
at += i + 1
}
if n == 0 {
return 0
}
}
lists := len(seen)
sort.SliceStable(hits, func(i, j int) bool { return hits[i].pos < hits[j].pos })
// smallest window holding at least one position of every word
counts := make([]int, lists)
have, left, best := 0, 0, -1
for right := range hits {
if counts[hits[right].list]++; counts[hits[right].list] == 1 {
have++
}
for have == lists {
if span := hits[right].pos - hits[left].pos; best < 0 || span < best {
best = span
}
if counts[hits[left].list]--; counts[hits[left].list] == 0 {
have--
}
left++
}
}
if best < 0 {
return 0
}
return 1000.0 / float64(best+1)
}
func sortVariants(original string, variants []string) []string {
type sv struct {
score int
v string
}
var scored []sv
orig := strings.ToLower(original)
for _, v := range variants {
lv := strings.ToLower(v)
prefix := 0
for prefix < len(orig) && prefix < len(lv) && orig[prefix] == lv[prefix] {
prefix++
}
if prefix < min(2, len(orig)/2) { // doesn't share the stem
continue
}
scored = append(scored, sv{prefix*1000 + len(v), v})
}
sort.SliceStable(scored, func(i, j int) bool { return scored[i].score > scored[j].score })
out := make([]string, len(scored))
for i, s := range scored {
out[i] = s.v
}
return out
}
func (a *App) fts(q string) []Verse {
v, err := a.store.FTS(q)
if err != nil { // FTS5 syntax the user typed by accident: just no results
lg.Info("fts %q: %v", q, err)
}
return v
}
func insertAfterResponsesOpen(page, notice string) string {
return strings.Replace(page, "<div class='responses'>", "<div class='responses'>"+notice, 1)
}
// fallbackResp shows the results of a rewritten query with an explanation.
func (a *App) fallbackResp(results []Verse, view, kw, plainNote, richNotice string, jsonObj map[string]any) Resp {
r := a.render(results, view, kw)
switch view {
case "plain":
return htmlR(plainNote + "\n<br>" + r.text)
case "rich":
return htmlR(insertAfterResponsesOpen(r.text, richNotice))
}
jsonObj["results"] = r.list
return jsonR(jsonObj)
}
func (a *App) handleSearch(kw, view string) Resp {
cleaned := searchClean(kw)
if words := strings.Fields(cleaned); len(words) > maxSearchWords || len(cleaned) > maxSearchLen {
return htmlR("Your query is too long. 20 words or less.")
}
if cleaned == "" {
return a.staticPage("html/search.html")
}
esc := html.EscapeString
// a common theological concept that is not in the KJV?
conceptNotice := ""
if c, ok := a.concepts[strings.ToLower(cleaned)]; ok {
var links []string
for _, t := range c.SearchTerms {
links = append(links, fmt.Sprintf("<a href='/search?kw=%s&view=rich'><strong>%s</strong></a>", strings.ReplaceAll(t, " ", "+"), t))
}
conceptNotice = fmt.Sprintf("<div class='correction-notice'><strong>%s</strong>: %s<br>You may find what you are looking for with these search terms: %s</div>",
capitalize(cleaned), c.Explanation, strings.Join(links, ", "))
cleaned = c.SearchTerms[0]
}
results := a.fts(cleaned)
words := strings.Fields(cleaned)
if len(results) > 0 && len(words) > 1 {
scores := make(map[int64]float64, len(results))
for _, v := range results {
scores[v.ID] = proximityScore(v.Text, words)
}
sort.SliceStable(results, func(i, j int) bool { return scores[results[i].ID] > scores[results[j].ID] })
}
if len(results) == 0 {
// 1. grammatical variants
for _, word := range words {
for _, variant := range sortVariants(word, a.variants[strings.ToLower(word)]) {
vq := strings.Replace(cleaned, word, variant, 1)
if r := a.fts(vq); len(r) > 0 {
return a.fallbackResp(r, view, kw,
fmt.Sprintf("Showing results for '%s' (grammatical variant of '%s')", vq, cleaned),
fmt.Sprintf("<div class='variant-notice'>Showing results for '<strong>%s</strong>' (grammatical variant of '%s')</div>", vq, cleaned),
map[string]any{"original": cleaned, "variant": vq})
}
}
}
// 2. spell check
corrected := make([]string, len(words))
changed := false
for i, w := range words {
corrected[i] = w
if !a.speller.Spell(w) {
if sug := a.speller.Suggest(w); len(sug) > 0 {
corrected[i], changed = sug[0], true
}
}
}
if changed {
ck := strings.Join(corrected, " ")
if r := a.fts(ck); len(r) > 0 {
return a.fallbackResp(r, view, kw,
fmt.Sprintf("Showing results for '%s' (corrected from '%s')", ck, cleaned),
fmt.Sprintf("<div class='correction-notice'>Showing results for '<strong>%s</strong>' (corrected from '%s')</div>", esc(ck), cleaned),
map[string]any{"corrected_from": cleaned, "corrected_to": ck})
}
}
// 3. theological equivalents
for _, word := range words {
for _, syn := range a.synonyms[strings.ToLower(word)] {
sq := strings.Replace(cleaned, word, syn, 1)
if r := a.fts(sq); len(r) > 0 {
explanation := fmt.Sprintf("In the KJV, '%s' and '%s' often refer to similar theological concepts. Consider also searching for related verses.", word, syn)
return a.fallbackResp(r, view, kw,
fmt.Sprintf("Showing results for '%s' (theological equivalent of '%s')\n%s", sq, cleaned, explanation),
fmt.Sprintf("<div class='theological-notice'>Showing results for '<strong>%s</strong>' (theological equivalent of '%s')<br><em>%s</em></div>", sq, cleaned, explanation),
map[string]any{"original": cleaned, "theological_equivalent": sq, "explanation": explanation})
}
}
}
// 4. nothing: offer spelling suggestions
var suggestions []string
for i, w := range words {
if a.speller.Spell(w) {
continue
}
for _, s := range a.speller.Suggest(w) {
if len(suggestions) >= 5 {
break
}
alt := append([]string(nil), words...)
alt[i] = s
suggestions = append(suggestions, strings.Join(alt, " "))
}
}
if view != "rich" {
list := "No suggestions available"
if len(suggestions) > 0 {
list = strings.Join(suggestions, ", ")
}
return htmlR(fmt.Sprintf("%s not found, try one of the following: %s", cleaned, list))
}
var sb strings.Builder
sb.WriteString(a.renderNavbar(cleaned, nil, ""))
sb.WriteString("<div class='responses'>")
fmt.Fprintf(&sb, "<div class='correction-notice'><strong>No results found</strong> for '%s'.</div>", cleaned)
if len(suggestions) > 0 {
sb.WriteString("<div class='theological-suggestions'><h3>Did you mean?</h3>")
sb.WriteString("<p>We couldn't find any verses matching your search. Here are some spelling suggestions:</p>")
for _, s := range suggestions {
fmt.Fprintf(&sb, "<div class='suggestion-item'><a href='/search?kw=%s&view=rich'><strong>%s</strong></a></div>", url.QueryEscape(s), esc(s))
}
sb.WriteString("</div>")
} else {
sb.WriteString("<div class='theological-suggestions'><h3>Search Tips</h3>")
sb.WriteString("<p>We couldn't find any verses matching your search. Try:</p>")
sb.WriteString("<ul style='font-family: var(--font-ui); color: var(--color-text-secondary);'>")
sb.WriteString("<li>Checking your spelling</li><li>Using different keywords</li>")
sb.WriteString("<li>Searching for <a href='/search?kw=love&view=rich'>love</a>, <a href='/search?kw=faith&view=rich'>faith</a>, or <a href='/search?kw=hope&view=rich'>hope</a></li>")
sb.WriteString("</ul></div>")
}
sb.WriteString("</div>")
return htmlR(sb.String())
}
r := a.render(results, view, kw)
var related []string
for _, w := range words {
related = append(related, a.synonyms[strings.ToLower(w)]...)
}
switch view {
case "plain":
out := r.text
if conceptNotice != "" {
out = conceptNotice + "<br>" + out
}
if len(related) > 0 {
out += "<br><br>Also consider searching: "
for _, s := range related {
out += s + "<br>"
}
}
return htmlR(out)
case "rich":
out := r.text
if conceptNotice != "" {
if start := strings.Index(out, "<div class='responses'>"); start != -1 {
if end := strings.Index(out[start+len("<div class='responses'>"):], "</div>"); end != -1 {
at := start + len("<div class='responses'>") + end + len("</div>")
out = out[:at] + conceptNotice + out[at:]
}
}
}
if len(related) > 0 {
var sb strings.Builder
sb.WriteString("<div class='theological-suggestions'><h3>Related Theological Concepts</h3>")
sb.WriteString("<p>In the KJV, these terms often refer to similar theological concepts. Consider also searching for related verses.</p>")
for _, s := range related {
fmt.Fprintf(&sb, "<div class='suggestion-item'><a href='/search?kw=%s&view=rich'><strong>%s</strong></a></div>", url.QueryEscape(s), capitalize(s))
}
sb.WriteString("</div>")
if at := strings.LastIndex(out, "</div>"); at != -1 {
out = out[:at] + sb.String() + out[at:]
}
}
return htmlR(out)
}
return r.resp()
}
// ---------------------------------------------------------------------------
// /find : scripture if it reads as scripture, otherwise a keyword search with definitions
func (a *App) handleFind(q url.Values) Resp {
query := strings.TrimSpace(q.Get("kw"))
if viewOf(q, "rich") != "rich" {
return jsonR(map[string]string{"Error": "/find endpoint only supports rich responses"})
}
lg.Info("Query: %s", query)
if query == "" {
return a.staticPage("html/find.html")
}
query = strings.TrimSuffix(query, ",")
// A query that reads as scripture references ("Exodus", "1 jn 3.16") is shown
// as scripture; anything else is a keyword search.
if resolved := a.res.Resolve(query, false); resolved != nil {
if resp, ok := a.show(query, "rich", false); ok {
notices := ""
for _, n := range resolved.Notes {
notices += fmt.Sprintf("<div class='correction-notice'>Showing <strong>%s</strong> (interpreted from '%s')</div>",
html.EscapeString(n.Name), html.EscapeString(n.Original))
}
if resolved.Bare {
keyword := url.QueryEscape(searchClean(query))
notices += fmt.Sprintf("<div class='correction-notice'>Showing <strong>%s</strong>, chapter 1. Looking for the word instead? "+
"<a href='/search?kw=%s&view=rich'>Search the text for '%s'</a></div>",
html.EscapeString(resolved.Label), keyword, html.EscapeString(query))
}
if notices != "" {
resp.Body = insertAfterResponsesOpen(resp.Body, notices)
}
return resp
}
}
if strings.TrimSpace(searchClean(query)) == "" {
return a.staticPage("html/find.html") // nothing searchable left (punctuation, accents)
}
var tokens []string
for _, w := range strings.Split(strings.ReplaceAll(query, ",", " "), " ") {
if w != "" {
tokens = append(tokens, w)
}
}
if len(tokens) > 20 {
return htmlR("Your Query is too long. 20 Words or less.")
}
result := a.handleSearch(q.Get("kw"), "rich")
body := strings.TrimSuffix(result.Body, "</div>") // reopen .responses to add the dictionary inside it
var defs strings.Builder
for _, word := range tokens {
entry, ok := a.dictionary[strings.ToLower(word)]
if !ok {
continue
}
defs.WriteString("<button type='button' class='collapsible'>" + html.EscapeString(capitalize(word)) + "</button>")
defs.WriteString("<div class='definition'>" + a.defineRich(strings.ToLower(word), entry, true) + "</div>")
}
if defs.Len() > 0 {
body += "<style>" + a.cssCollapsible + "</style>"
body += "<div class='dictionary-section'><h3>1828 Webster Definitions</h3><p>Click on the word to get the definition.</p></div>"
body += defs.String()
body += "<script src='" + a.assetURL("/js/collapsible.js") + "'></script>"
}
return htmlR(body + "</div>")
}
+357
View File
@@ -0,0 +1,357 @@
package kjv
// Scripture reference resolver. Understands the many ways people type a
// reference ("1 jn 3.16", "II Kings 2:1-4", "song of songs 2", "Exodus ") and
// turns it into ranges of verse ids (book*1000000 + chapter*1000 + verse).
import (
"regexp"
"sort"
"strconv"
"strings"
)
// Extra spellings people use that the abbreviations table does not know.
var extraBookAliases = map[string][]string{
"Song of Solomon": {"song of songs", "songs of solomon", "songs of songs", "song of sol",
"song of sol", "canticles", "canticle of canticles", "sos", "sng", "ss"},
"Psalms": {"psalm", "psalter", "pslm", "psa", "pss", "psm", "ps"},
"Revelation": {"revelations", "revelation of john", "revelation of st john", "the revelation", "apocalypse"},
"Ecclesiastes": {"ecclesiasts", "eccles", "ecc", "qoheleth"},
"Philemon": {"phlm", "phm", "philem"},
"Judges": {"jdg", "jdgs", "jg"},
"Mark": {"mrk", "mk", "mr"},
"John": {"jhn", "jn"},
"Acts": {"acts of the apostles", "act"},
"James": {"jas", "jm"},
}
// Words that are books but also ordinary search words: a bare "numbers" or
// "mark" still shows the book, but /find offers a keyword search as well.
var bareBookExtra = map[string]bool{
"psalm": true, "songofsongs": true, "songsofsolomon": true, "songofsol": true, "canticles": true,
"canticleofcanticles": true, "revelations": true, "revelationofjohn": true, "therevelation": true,
}
const maxChapterIDSpan = 999
// A reference is never this long; longer text goes to the keyword search. Bounds the fuzzy matching work.
const maxRefQueryLen = 300
// Go's regexp has no lookaheads, so they are written as a consumed [\s.] here, which matches the same text.
var (
bookPieceRE = regexp.MustCompile(`(?i)^\s*(?P<book>(?:(?:[1-3]|(?:iii|ii|i)[\s.]|1st|2nd|3rd|first|second|third)\.?\s*)?[a-z][a-z.\s]*?)` +
`\s*(?P<rest>\d[\d\s:.\-–—]*)?\s*$`)
continuationRE = regexp.MustCompile(`^\s*\d[\d\s:.\-–—]*$`)
reBookOf = regexp.MustCompile(`^(?:the\s+)?(?:book\s+of\s+)`)
reFirst = regexp.MustCompile(`^(?:1st|first)([\s.a-z]|$)`)
reSecond = regexp.MustCompile(`^(?:2nd|second)([\s.a-z]|$)`)
reThird = regexp.MustCompile(`^(?:3rd|third)([\s.a-z]|$)`)
reRoman3 = regexp.MustCompile(`^iii([\s.]|$)`)
reRoman2 = regexp.MustCompile(`^ii([\s.]|$)`)
reRoman1 = regexp.MustCompile(`^i([\s.])`)
reNotAlnum = regexp.MustCompile(`[^a-z0-9]`)
reSepSpace = regexp.MustCompile(`\s*([:\-])\s*`)
reChapter = regexp.MustCompile(`^(\d+)$`)
reChapterAll = regexp.MustCompile(`^(\d+)(?::-|-)$`)
reChapterRange = regexp.MustCompile(`^(\d+)-(\d+)$`)
reVerse = regexp.MustCompile(`^(\d+)[:.\s](\d+)$`)
reVerseOn = regexp.MustCompile(`^(\d+)[:.\s](\d+)-$`)
reVerseRange = regexp.MustCompile(`^(\d+)[:.\s](\d+)-(\d+)$`)
reVerseAcrossCh = regexp.MustCompile(`^(\d+)[:.\s](\d+)-(\d+)[:.](\d+)$`)
)
// normalizeBookText: 'I Cor.' / '1st Corinthians' / '1 corinthians' -> '1corinthians'
func normalizeBookText(text string) string {
text = strings.ToLower(strings.TrimSpace(text))
text = reBookOf.ReplaceAllString(text, "")
text = reFirst.ReplaceAllString(text, "1${1}")
text = reSecond.ReplaceAllString(text, "2${1}")
text = reThird.ReplaceAllString(text, "3${1}")
text = reRoman3.ReplaceAllString(text, "3${1}")
text = reRoman2.ReplaceAllString(text, "2${1}")
text = reRoman1.ReplaceAllString(text, "1${1}")
return reNotAlnum.ReplaceAllString(text, "")
}
type Resolver struct {
store *Store
canonical map[string]int
aliases map[string]int
aliasKeys []string
canonKeys []string
}
func NewResolver(s *Store) *Resolver {
r := &Resolver{store: s, canonical: map[string]int{}, aliases: map[string]int{}}
for id, name := range s.bookName {
r.canonical[normalizeBookText(name)] = id
}
for k, v := range r.canonical {
r.aliases[k] = v
}
for _, a := range s.abbrevs {
if k := normalizeBookText(a.Name); r.aliases[k] == 0 {
r.aliases[k] = a.Book
}
}
for name, extras := range extraBookAliases {
id := s.bookByName[name]
for _, extra := range extras {
r.aliases[normalizeBookText(extra)] = id
}
}
for k := range r.aliases {
r.aliasKeys = append(r.aliasKeys, k)
}
sort.Strings(r.aliasKeys)
for k := range r.canonical {
r.canonKeys = append(r.canonKeys, k)
}
sort.Strings(r.canonKeys)
return r
}
type bookMatch struct {
ID int
How string // exact | prefix | fuzzy
}
// resolveBook finds a book from a typed name. loose=false only accepts full
// names and spellings that are unmistakably a book (used for a bare word with
// no chapter number).
func (r *Resolver) resolveBook(token string, loose bool) (bookMatch, bool) {
key := normalizeBookText(token)
if key == "" {
return bookMatch{}, false
}
if !loose {
if _, ok := r.canonical[key]; ok || bareBookExtra[key] {
return bookMatch{r.aliases[key], "exact"}, true
}
// a clear misspelling of a long full name ("genisis")
if len(key) >= 6 && len(key) <= 40 && !(key[0] >= '0' && key[0] <= '9') {
if close, ok := closestMatch(key, r.canonKeys, 0.85); ok {
return bookMatch{r.canonical[close], "fuzzy"}, true
}
}
return bookMatch{}, false
}
if id, ok := r.aliases[key]; ok {
return bookMatch{id, "exact"}, true
}
// unique (or best) prefix: "deut", "1cor", "matth"
books := map[int]bool{}
for _, k := range r.aliasKeys {
if strings.HasPrefix(k, key) {
books[r.aliases[k]] = true
}
}
if len(books) > 0 {
canonHits := map[int]bool{}
for _, k := range r.canonKeys {
if strings.HasPrefix(k, key) {
canonHits[r.canonical[k]] = true
}
}
if len(canonHits) == 1 {
return bookMatch{anyKey(canonHits), "prefix"}, true
}
if len(books) == 1 {
return bookMatch{anyKey(books), "prefix"}, true
}
pool := books
if len(canonHits) > 0 {
pool = canonHits
}
best := 0
for id := range pool {
if id > best {
best = id // ambiguous: the later book, as before
}
}
return bookMatch{best, "prefix"}, true
}
if len(key) >= 4 && len(key) <= 40 {
if close, ok := closestMatch(key, r.aliasKeys, 0.78); ok {
return bookMatch{r.aliases[close], "fuzzy"}, true
}
}
return bookMatch{}, false
}
func anyKey(m map[int]bool) int {
for k := range m {
return k
}
return 0
}
// clampChapter: 0 -> 1, past the end -> the last chapter.
func (r *Resolver) clampChapter(book, chapter int) int {
last := r.store.chapterLen[book]
if chapter > last {
chapter = last
}
if chapter < 1 {
chapter = 1
}
return chapter
}
// atoi never fails: absurdly long numbers saturate.
func atoi(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
return 1_000_000
}
if n > 1_000_000 {
return 1_000_000
}
return n
}
type refPos struct{ book, chapter, verse int }
// parseRefRest: '3:16-18', '3 16', '3.16', '3-4', '3:16-4:2', '3', '3:-' -> (start, end or 0, position)
func (r *Resolver) parseRefRest(rest string, book int, previous *refPos) (start, end int64, pos refPos, ok bool) {
rest = strings.NewReplacer("–", "-", "—", "-").Replace(rest)
rest = reSepSpace.ReplaceAllString(strings.TrimSpace(rest), "$1")
base := int64(book) * 1_000_000
at := func(chapter, verse int) int64 { return base + int64(chapter)*1000 + int64(verse) }
pos.book = book
if m := reChapter.FindStringSubmatch(rest); m != nil {
n := atoi(m[1])
if previous != nil && previous.verse != 0 { // "John 3:16, 18": a verse of the same chapter
pos.chapter, pos.verse = previous.chapter, n
return at(previous.chapter, n), 0, pos, true
}
ch := r.clampChapter(book, n)
pos.chapter = ch
return at(ch, 1), at(ch, maxChapterIDSpan), pos, true
}
if m := reChapterAll.FindStringSubmatch(rest); m != nil { // "3:-" or "3-": the whole chapter
ch := r.clampChapter(book, atoi(m[1]))
pos.chapter = ch
return at(ch, 1), at(ch, maxChapterIDSpan), pos, true
}
if m := reChapterRange.FindStringSubmatch(rest); m != nil { // chapters 3 to 4
first, last := r.clampChapter(book, atoi(m[1])), r.clampChapter(book, atoi(m[2]))
if last < first {
first, last = last, first
}
pos.chapter = first
return at(first, 1), at(last, maxChapterIDSpan), pos, true
}
if m := reVerse.FindStringSubmatch(rest); m != nil {
ch := r.clampChapter(book, atoi(m[1]))
verse := max(1, atoi(m[2]))
pos.chapter, pos.verse = ch, verse
return at(ch, verse), 0, pos, true
}
if m := reVerseOn.FindStringSubmatch(rest); m != nil { // "3:16-": from verse 16 to the end
ch := r.clampChapter(book, atoi(m[1]))
pos.chapter, pos.verse = ch, atoi(m[2])
return at(ch, max(1, atoi(m[2]))), at(ch, maxChapterIDSpan), pos, true
}
if m := reVerseRange.FindStringSubmatch(rest); m != nil {
ch := r.clampChapter(book, atoi(m[1]))
first, last := max(1, atoi(m[2])), max(1, atoi(m[3]))
if last < first {
first, last = last, first
}
pos.chapter, pos.verse = ch, first
return at(ch, first), at(ch, last), pos, true
}
if m := reVerseAcrossCh.FindStringSubmatch(rest); m != nil { // 3:16-4:2 across chapters
c1, c2 := r.clampChapter(book, atoi(m[1])), r.clampChapter(book, atoi(m[3]))
s, e := at(c1, max(1, atoi(m[2]))), at(c2, max(1, atoi(m[4])))
if e < s {
return 0, 0, pos, false
}
pos.chapter, pos.verse = c1, atoi(m[2])
return s, e, pos, true
}
return 0, 0, pos, false
}
type RefRange struct{ Start, End int64 } // End == 0: a single verse
type Note struct{ Original, Name string }
// Resolved is a whole parsed query.
type Resolved struct {
Refs []RefRange
Notes []Note // short explanations of guesses that were made
Bare bool // the query was just a book name (shows its first chapter)
Label string // the name of the first book, for messages
}
// Resolve parses a whole query ('John 3:16, Romans 5:8') into verse id ranges.
// It returns nil when the text does not read as scripture references, so the
// caller can treat it as a keyword search.
func (r *Resolver) Resolve(text string, looseBare bool) *Resolved {
if strings.TrimSpace(text) == "" || len(text) > maxRefQueryLen {
return nil
}
var pieces []string
for _, p := range regexp.MustCompile(`[,;\n]+`).Split(text, -1) {
if strings.TrimSpace(p) != "" {
pieces = append(pieces, p)
}
}
if len(pieces) == 0 || len(pieces) > 20 {
return nil
}
out := &Resolved{}
var previous *refPos
for _, piece := range pieces {
var start, end int64
var pos refPos
var name string
if m := bookPieceRE.FindStringSubmatchIndex(piece); m != nil {
bi, ri := bookPieceRE.SubexpIndex("book"), bookPieceRE.SubexpIndex("rest")
token := piece[m[2*bi]:m[2*bi+1]]
hasRest := m[2*ri] >= 0
found, ok := r.resolveBook(token, hasRest || looseBare)
if !ok {
return nil
}
name = r.store.BookName(found.ID)
if found.How == "fuzzy" {
out.Notes = append(out.Notes, Note{strings.TrimSpace(token), name})
}
if !hasRest {
if len(pieces) == 1 {
out.Bare = true
}
base := int64(found.ID) * 1_000_000
start, end, pos = base+1001, base+1999, refPos{found.ID, 1, 0}
} else {
var ok bool
start, end, pos, ok = r.parseRefRest(piece[m[2*ri]:m[2*ri+1]], found.ID, nil)
if !ok {
return nil
}
}
} else if continuationRE.MatchString(piece) && previous != nil {
name = r.store.BookName(previous.book)
var ok bool
start, end, pos, ok = r.parseRefRest(piece, previous.book, previous)
if !ok {
return nil
}
} else {
return nil
}
p := pos
previous = &p
if out.Label == "" {
out.Label = name
}
out.Refs = append(out.Refs, RefRange{start, end})
}
return out
}
+163
View File
@@ -0,0 +1,163 @@
package kjv
import (
"crypto/subtle"
"database/sql"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"time"
)
// Optional endpoints backed by the separate sermon database (KJV_SERMON_DB)
// and the separate semantic search service (KJV_SEMANTIC_ADDR, default
// localhost:1613). Neither is part of the distribution; without them these
// endpoints answer with an error.
func rowsToMaps(rows *sql.Rows) ([]map[string]any, error) {
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return nil, err
}
out := []map[string]any{}
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return nil, err
}
m := map[string]any{}
for i, c := range cols {
if b, ok := vals[i].([]byte); ok {
vals[i] = string(b)
}
m[c] = vals[i]
}
out = append(out, m)
}
return out, rows.Err()
}
func (a *App) sermonQuery(query string, args ...any) Resp {
if a.sermonDB == nil {
return jsonStatus(map[string]string{"error": "Sermon database not found"}, http.StatusServiceUnavailable)
}
rows, err := a.sermonDB.Query(query, args...)
if err != nil {
lg.Error("sermon query: %v", err)
return errJSON(http.StatusInternalServerError, "Sermon database error")
}
out, err := rowsToMaps(rows)
if err != nil {
return errJSON(http.StatusInternalServerError, "Sermon database error")
}
return jsonR(out)
}
const sermonCols = "select distinct title,url,denomination,preacher,church_name from verse_refs where "
// handleSermons serves everything under /sermon/ and /sermons/ ... by hand,
// since the paths overlap in ways the router would reject.
func (a *App) handleSermons(path string) Resp {
parts := strings.Split(strings.Trim(path, "/"), "/")
switch {
case len(parts) == 1 && parts[0] == "sermons":
return a.sermonQuery("select distinct title,url,denomination,preacher,church_name,filename from verse_refs")
case len(parts) == 3 && parts[0] == "sermons" && parts[1] == "preacher":
return a.sermonQuery(sermonCols+"preacher=?", parts[2])
case len(parts) == 3 && parts[0] == "sermons" && parts[1] == "church":
return a.sermonQuery(sermonCols+"church_name=?", parts[2])
case len(parts) == 3 && parts[0] == "sermons" && parts[1] == "denomination":
return a.sermonQuery(sermonCols+"denomination=?", parts[2])
case len(parts) == 3 && parts[0] == "sermon" && parts[1] == "search":
return a.sermonsByRef(parts[2])
case len(parts) == 3 && parts[0] == "sermon" && parts[1] == "url":
return a.sermonQuery(sermonCols+"url=?", parts[2])
case len(parts) == 3 && parts[0] == "sermon" && parts[2] == "verses":
return a.sermonQuery("select distinct verse_id,text from verse_refs where filename=?", parts[1])
case len(parts) == 2 && parts[0] == "sermon":
return a.sermonQuery(sermonCols+"filename=?", parts[1])
}
return a.notFound()
}
func (a *App) sermonsByRef(ref string) Resp {
id, ok := a.refVerseID(ref)
if !ok {
return a.notFound()
}
if a.sermonDB == nil {
return jsonR(map[string]string{"error": "Sermon database not found"})
}
rows, err := a.sermonDB.Query("select * from verse_refs where verse_id=?", id)
if err != nil {
return errJSON(http.StatusInternalServerError, "Sermon database error")
}
out, err := rowsToMaps(rows)
if err != nil {
return errJSON(http.StatusInternalServerError, "Sermon database error")
}
for _, m := range out {
if vid, ok := m["verse_id"].(int64); ok {
if v := a.store.Range(vid, 0); len(v) == 1 {
m["kjv"] = v[0].Text
}
}
}
return jsonR(out)
}
// refVerseID: the verse id of the first reference in "1John5:7" style text.
func (a *App) refVerseID(ref string) (int64, bool) {
resolved := a.res.Resolve(ref, true)
if resolved == nil {
return 0, false
}
return resolved.Refs[0].Start, true
}
func (a *App) handleRef(ref string) Resp {
id, ok := a.refVerseID(ref)
if !ok {
return a.notFound()
}
return htmlR(itoa64(id))
}
func (a *App) handleNaturalPost(body io.Reader) Resp {
var req struct {
Token *string `json:"token"`
Query string `json:"query"`
}
if err := json.NewDecoder(io.LimitReader(body, 1<<16)).Decode(&req); err != nil {
return errJSON(http.StatusBadRequest, "Invalid request format")
}
if req.Token == nil {
return errJSON(http.StatusUnauthorized, "Missing token in request body")
}
if a.secretKey == "" || subtle.ConstantTimeCompare([]byte(*req.Token), []byte(a.secretKey)) != 1 {
return errJSON(http.StatusUnauthorized, "Invalid Authorization")
}
conn, err := net.DialTimeout("tcp", a.semantic, 3*time.Second)
if err != nil {
return errJSON(http.StatusBadGateway, "Semantic search is not available")
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(30 * time.Second))
payload, _ := json.Marshal(map[string]string{"query": req.Query})
if _, err := conn.Write(payload); err != nil {
return errJSON(http.StatusBadGateway, "Semantic search is not available")
}
data, err := io.ReadAll(io.LimitReader(conn, 8<<20))
var out any
if err != nil || json.Unmarshal(data, &out) != nil {
return errJSON(http.StatusBadGateway, "Semantic search gave an invalid answer")
}
return jsonR(out)
}
+313
View File
@@ -0,0 +1,313 @@
package kjv
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"runtime"
"strconv"
"syscall"
"time"
)
// localOnly insists on a loopback address: the program is meant to sit behind
// a reverse proxy on the same machine and never face the network itself.
func localOnly(addr string) (string, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return "", fmt.Errorf("bad listen address %q: %w", addr, err)
}
switch host {
case "", "localhost":
host = "127.0.0.1"
default:
if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
return "", fmt.Errorf("refusing to listen on %q: only localhost addresses (127.0.0.1, ::1) are allowed; put a reverse proxy in front", host)
}
}
return net.JoinHostPort(host, port), nil
}
// Run serves the application from the companion database until interrupted.
func Run(dbPath, addr string) error {
addr, err := localOnly(addr)
if err != nil {
return err
}
if _, err := os.Stat(dbPath); err != nil {
return fmt.Errorf("database not found: %s (use -db or KJV_DB)", dbPath)
}
store, err := OpenStore(dbPath)
if err != nil {
return err
}
defer store.Close()
if store.stateOK {
lg.Attach(store.db, 90)
} else {
fmt.Fprintln(os.Stderr, "WARN: database is read-only: logs go to stderr; reading plans and shortcodes are disabled")
}
defer lg.Close()
app, err := NewApp(store)
if err != nil {
return err
}
app.secretKey = os.Getenv("KJV_SECRET_API_KEY")
app.semantic = envOr("KJV_SEMANTIC_ADDR", "localhost:1613")
if p := os.Getenv("KJV_SERMON_DB"); p != "" {
if st, err := os.Stat(p); err == nil && st.Size() > 0 {
if db, err := sql.Open("sqlite", "file:"+p+"?mode=ro&_pragma=busy_timeout(5000)"); err == nil {
app.sermonDB = db
}
} else {
lg.Warn("sermon database %s not available", p)
}
}
srv := &http.Server{
Addr: addr,
Handler: app.Routes(),
MaxHeaderBytes: 16 << 10,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go app.maintain()
lg.Info("Started %s: db=%s addr=%s verses=%d state=%v sermons=%v", runtime.Version(), dbPath, addr, len(store.verses), store.stateOK, app.sermonDB != nil)
fmt.Printf("kjv-api listening on %s (database %s)\n", addr, dbPath)
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errc:
return err
case s := <-sig:
lg.Info("Stopping on %v", s)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
}
return nil
}
// ---------------------------------------------------------------------------
// routes
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) { w.status = code; w.ResponseWriter.WriteHeader(code) }
func (a *App) Routes() http.Handler {
mux := http.NewServeMux()
get := func(pattern string, h func(r *http.Request) Resp) {
mux.HandleFunc("GET "+pattern, func(w http.ResponseWriter, r *http.Request) { h(r).write(w) })
}
get("/{$}", func(r *http.Request) Resp { return htmlR(a.indexHTML) })
get("/{shortcode}", func(r *http.Request) Resp { return a.handleShortcode(r.PathValue("shortcode")) })
get("/fe", func(r *http.Request) Resp { return a.staticPage("kjv_fe.html") })
get("/salvation", func(r *http.Request) Resp { return a.staticPage("html/heaven.html") })
get("/saved", func(r *http.Request) Resp { return a.staticPage("html/saved.html") })
get("/plan", func(r *http.Request) Resp { return a.staticPage("html/plan.html") })
get("/show", func(r *http.Request) Resp { return a.handleShow(r.URL.Query()) })
get("/find", func(r *http.Request) Resp { return a.handleFind(r.URL.Query()) })
get("/search", func(r *http.Request) Resp {
v := r.URL.Query()
return a.handleSearch(v.Get("kw"), viewOf(v, "rich"))
})
get("/define", func(r *http.Request) Resp { return a.handleDefine(r.URL.Query()) })
get("/defs", func(r *http.Request) Resp { return a.handleDefs(r.URL.Query()) })
get("/random", func(r *http.Request) Resp { return a.handleRandom(r.URL.Query(), "json") })
get("/t/random", func(r *http.Request) Resp { return a.handleRandom(r.URL.Query(), "plain") })
get("/votd", func(r *http.Request) Resp { return a.handleVotd(r.URL.Query(), "json") })
get("/t/votd", func(r *http.Request) Resp { return a.handleVotd(r.URL.Query(), "plain") })
get("/seq", func(r *http.Request) Resp {
if r.URL.Query().Get("getuid") != "" && !creations.allow(clientAddr(r)) {
return tooMany()
}
return a.handleSeq(r.URL.Query())
})
get("/mksc/{query}", func(r *http.Request) Resp {
if !creations.allow(clientAddr(r)) {
return tooMany()
}
return a.handleMakeShortcode(r.PathValue("query"))
})
get("/ref/{ref}", func(r *http.Request) Resp { return a.handleRef(r.PathValue("ref")) })
get("/verse_id/{id}", func(r *http.Request) Resp {
id := cleanInput(r.PathValue("id"), "0123456789")
n, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return errJSON(http.StatusBadRequest, "Invalid verse id")
}
v := a.store.Range(n, 0)
if len(v) == 0 {
return errJSON(http.StatusNotFound, "No such verse")
}
return jsonR([]any{v[0].ID, v[0].Book, v[0].Chapter, v[0].Verse, v[0].Text})
})
get("/xref/{id}", func(r *http.Request) Resp { return a.handleXref(r.PathValue("id")) })
get("/browse", func(r *http.Request) Resp {
body := "<h2>Books</h2><div class=\"table-wrap\"><table id=\"Books\">"
for _, n := range a.store.bookNames {
body += fmt.Sprintf("<tr><td><a href=\"/find?kw=%s&view=rich\">%s</a></td></tr>", urlQuery(n), htmlEsc(n))
}
return htmlR(a.staticHTMLDoc("Books", body+"</table></div>"))
})
get("/status", func(r *http.Request) Resp {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return htmlR(a.staticHTMLDoc("Status", fmt.Sprintf("<h2>Status</h2><p>Sanity: %v</p><p>Uptime: %s</p><p>Memory: %d KB</p>",
a.store.stateOK, time.Since(a.started).Round(time.Second), m.Sys/1024)))
})
get("/seqreport", func(r *http.Request) Resp {
if resp, ok := a.adminOnly(r); !ok {
return resp
}
return a.tableReport("Sequence Report", "seq_report", "SELECT uid, verse, last_used FROM reading_tracker", false)
})
get("/screport", func(r *http.Request) Resp {
if resp, ok := a.adminOnly(r); !ok {
return resp
}
return a.tableReport("Shortcode Report", "shortcode_report", "SELECT shortcode, query, creation_date FROM shortcodes", true)
})
get("/natural", func(r *http.Request) Resp {
return htmlR("Submit a POST request to this endpoint with the following params in the body:\n{\"token\": \"mytokenstring\", \"query\" : \"myquerystring\"}")
})
mux.HandleFunc("POST /natural", func(w http.ResponseWriter, r *http.Request) { a.handleNaturalPost(r.Body).write(w) })
mux.HandleFunc("POST /plan/new", func(w http.ResponseWriter, r *http.Request) {
if !creations.allow(clientAddr(r)) {
tooMany().write(w)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if r.ParseForm() != nil {
errJSON(http.StatusRequestEntityTooLarge, "Request too large").write(w)
return
}
a.handlePlanNew(r.PostForm).write(w)
})
mux.HandleFunc("POST /plan/{uid}/move", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if r.ParseForm() != nil {
errJSON(http.StatusRequestEntityTooLarge, "Request too large").write(w)
return
}
a.handlePlanMove(r.PathValue("uid"), r.PostForm).write(w)
})
get("/plan/{uid}", func(r *http.Request) Resp { return a.handlePlanView(r.PathValue("uid"), r.URL.Query()) })
for _, p := range []string{"/sermons", "/sermons/{rest...}", "/sermon/{rest...}"} {
get(p, func(r *http.Request) Resp { return a.handleSermons(r.URL.Path) })
}
for _, p := range []string{"/preachers", "/churches", "/denominations"} {
cols := map[string]string{"/preachers": "denomination,preacher,church_name", "/churches": "denomination,church_name", "/denominations": "denomination"}[p]
get(p, func(r *http.Request) Resp { return a.sermonQuery("select distinct " + cols + " from verse_refs") })
}
for u := range servedPaths {
u := u
mux.HandleFunc("GET "+u, func(w http.ResponseWriter, r *http.Request) { a.serveAsset(w, r, u) })
}
favicon := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(a.favicon)
}
mux.HandleFunc("GET /favicon.ico", favicon)
mux.HandleFunc("GET /favicon.svg", favicon)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { a.notFound().write(w) })
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: 200}
defer func() {
if rec := recover(); rec != nil {
lg.Error("panic serving %s: %v", r.URL.String(), rec)
sw.status = 500
errJSON(500, "Internal error").write(w)
}
lg.Info("%s %s %d %s", r.Method, r.URL.RequestURI(), sw.status, time.Since(start).Round(time.Microsecond))
}()
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
mux.ServeHTTP(sw, r)
})
}
// serveAsset: a URL whose ?v= hash matches the file is immutable for a year;
// any other URL for it is revalidated every time.
func (a *App) serveAsset(w http.ResponseWriter, r *http.Request, u string) {
as := a.served[u]
h := w.Header()
h.Set("Content-Type", as.mime)
h.Set("ETag", as.etag)
if r.URL.Query().Get("v") == as.version {
h.Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
h.Set("Cache-Control", "no-cache")
}
if r.Header.Get("If-None-Match") == as.etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Write(as.body)
}
func (a *App) handleXref(raw string) Resp {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return a.notFound()
}
refs, err := a.store.CrossReferences(id)
if err != nil {
lg.Error("xref: %v", err)
return errJSON(http.StatusInternalServerError, "Database error")
}
out := []map[string]any{}
for _, c := range refs {
ref := a.store.FormatRef(c.Start)
var verses []Verse
if c.End > c.Start {
if c.Start/1000 == c.End/1000 { // "John 3:16-18" when the range stays in one chapter
ref += fmt.Sprintf("-%d", c.End%1000)
} else {
ref += "-" + a.store.FormatRef(c.End)
}
verses = a.store.Range(c.Start, c.End)
} else {
verses = a.store.Range(c.Start, 0)
}
text := ""
for i, v := range verses {
if i > 0 {
text += " "
}
text += v.Text
}
out = append(out, map[string]any{"ref": ref, "verse_id": c.Start, "text": text})
}
return jsonR(out)
}
+253
View File
@@ -0,0 +1,253 @@
package kjv
import (
"sort"
"strings"
"unicode"
)
// Speller checks and suggests spellings from kjv.dic (a plain word list) and
// kjv.aff (which only adds a TRY order and REP replacement hints), so no C
// library is needed.
type Speller struct {
words map[string]bool // exact entries
byLower map[string][]string // lowercase -> entries
list []string // lowercase entries, dictionary order, unique
try []rune
rep [][2]string
}
func NewSpeller(dic, aff []byte) *Speller {
s := &Speller{words: map[string]bool{}, byLower: map[string][]string{}}
lines := strings.Split(string(dic), "\n")
seen := map[string]bool{}
for i, line := range lines {
line = strings.TrimSpace(line)
if i == 0 || line == "" { // first line is the entry count
continue
}
if k := strings.IndexByte(line, '/'); k >= 0 {
line = line[:k]
}
s.words[line] = true
low := strings.ToLower(line)
s.byLower[low] = append(s.byLower[low], line)
if !seen[low] {
seen[low] = true
s.list = append(s.list, low)
}
}
for _, line := range strings.Split(string(aff), "\n") {
f := strings.Fields(line)
switch {
case len(f) == 2 && f[0] == "TRY":
s.try = []rune(f[1])
case len(f) == 3 && f[0] == "REP":
s.rep = append(s.rep, [2]string{f[1], strings.ReplaceAll(f[2], "_", " ")})
}
}
return s
}
func stripSyntax(w string) string {
return strings.Map(func(r rune) rune {
if r == '+' || r == '^' || r == '*' {
return -1
}
return r
}, w)
}
func hasLetter(w string) bool {
for _, r := range w {
if unicode.IsLetter(r) {
return true
}
}
return false
}
func (s *Speller) Spell(word string) bool {
word = stripSyntax(word)
if !hasLetter(word) || s.words[word] {
return true
}
low := strings.ToLower(word)
if low != word && (word == strings.ToUpper(word) || word == capitalize(low)) {
return len(s.byLower[low]) > 0
}
return false
}
func capitalize(w string) string {
r := []rune(w)
if len(r) == 0 {
return w
}
return strings.ToUpper(string(r[:1])) + strings.ToLower(string(r[1:]))
}
func (s *Speller) known(low string) []string { return s.byLower[low] }
// Suggest returns likely corrections, best first.
func (s *Speller) Suggest(word string) []string {
word = stripSyntax(word)
low := strings.ToLower(word)
if low == "" {
return nil
}
var out []string
seen := map[string]bool{}
add := func(cands ...string) {
for _, c := range cands {
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
}
addLower := func(l string) { // dictionary form(s) of a lowercase candidate
if l == low {
return
}
add(s.known(l)...)
}
if low != word { // wrong capitalization of a known word
add(s.known(low)...)
}
// replacement hints from the .aff file, applied at each occurrence
for _, r := range s.rep {
for at := 0; ; {
i := strings.Index(low[at:], r[0])
if i < 0 {
break
}
i += at
cand := low[:i] + r[1] + low[i+len(r[0]):]
if strings.Contains(cand, " ") {
if p := strings.Fields(cand); len(p) == 2 && len(s.known(p[0])) > 0 && len(s.known(p[1])) > 0 {
add(p[0] + " " + p[1])
}
} else {
addLower(cand)
}
at = i + 1
}
}
rs := []rune(low)
// one edit away: swap, delete, replace, insert
for i := 0; i+1 < len(rs); i++ {
c := append([]rune(nil), rs...)
c[i], c[i+1] = c[i+1], c[i]
addLower(string(c))
}
for i := range rs {
addLower(string(rs[:i]) + string(rs[i+1:]))
}
for i := range rs {
for _, t := range s.try {
addLower(string(rs[:i]) + string(t) + string(rs[i+1:]))
}
}
for i := 0; i <= len(rs); i++ {
for _, t := range s.try {
addLower(string(rs[:i]) + string(t) + string(rs[i:]))
}
}
// two words run together ("lordgod")
for i := 1; i < len(rs); i++ {
a, b := string(rs[:i]), string(rs[i:])
if len(a) > 1 && len(b) > 1 && len(s.known(a)) > 0 && len(s.known(b)) > 0 {
add(a + " " + b)
}
}
// still little to offer: anything within two edits, closest first
if len(out) < 3 {
type cand struct {
w string
dist int
pre int
}
var cs []cand
for _, w := range s.list {
if abs(len(w)-len(low)) > 2 || w == low {
continue
}
if d := editDistance(low, w, 2); d <= 2 {
cs = append(cs, cand{w, d, commonPrefix(low, w)})
}
}
sort.SliceStable(cs, func(i, j int) bool {
if cs[i].dist != cs[j].dist {
return cs[i].dist < cs[j].dist
}
return cs[i].pre > cs[j].pre
})
for _, c := range cs {
addLower(c.w)
if len(out) >= 8 {
break
}
}
}
if len(out) > 10 {
out = out[:10]
}
if word == strings.ToUpper(word) && len(word) > 1 {
for i := range out {
out[i] = strings.ToUpper(out[i])
}
} else if word == capitalize(low) && word != low {
for i := range out {
out[i] = capitalize(out[i])
}
}
return out
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func commonPrefix(a, b string) int {
n := 0
for n < len(a) && n < len(b) && a[n] == b[n] {
n++
}
return n
}
// editDistance is optimal-string-alignment distance, giving up above limit.
func editDistance(a, b string, limit int) int {
ra, rb := []rune(a), []rune(b)
prev2 := make([]int, len(rb)+1)
prev := make([]int, len(rb)+1)
cur := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur[0] = i
best := cur[0]
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
v := min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
if i > 1 && j > 1 && ra[i-1] == rb[j-2] && ra[i-2] == rb[j-1] {
v = min(v, prev2[j-2]+1)
}
cur[j] = v
best = min(best, v)
}
if best > limit {
return limit + 1
}
prev2, prev, cur = prev, cur, prev2
}
return prev[len(rb)]
}
+521
View File
@@ -0,0 +1,521 @@
package kjv
import (
"crypto/rand"
"crypto/subtle"
"database/sql"
"fmt"
"html"
"math"
"math/big"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// Reading sequences (/seq), reading plans (/plan) and shortcodes (/mksc) keep
// their rows in the companion database.
const (
idChars = "0123456789ACEFHJKLMNPRTUVWXY"
planMaxPerDay = 500
seqPurgeAfter = 90 * 24 * time.Hour
stateTimeFormat = "2006-01-02 15:04:05.000000"
)
func generateShortID() string { // 5 characters
n, err := rand.Int(rand.Reader, big.NewInt(17_210_367-614_656))
if err != nil {
panic(err)
}
remain := 614_656 + int(n.Int64())
out := ""
for remain > 0 {
out = string(idChars[remain%len(idChars)]) + out
remain /= len(idChars)
}
return out
}
func stateNow() string { return time.Now().Format(stateTimeFormat) }
var errNoState = fmt.Errorf("the database is read-only, so reading plans and shortcodes are unavailable")
func (a *App) stateDB() (*sql.DB, error) {
if !a.store.stateOK {
return nil, errNoState
}
return a.store.db, nil
}
// insertID stores a row under a fresh random id.
func insertID(db *sql.DB, query string, args ...any) (string, error) {
var err error
for i := 0; i < 5; i++ { // a clash on a random 5 character id is very unlikely
id := generateShortID()
if _, err = db.Exec(query, append([]any{id}, args...)...); err == nil {
return id, nil
}
}
return "", err
}
// ---------------------------------------------------------------------------
// shortcodes
func (a *App) handleMakeShortcode(query string) Resp {
query = cleanInput(query, searchChars+":-,")
if query == "" {
return htmlR("Empty query.")
}
db, err := a.stateDB()
if err != nil {
return errJSON(http.StatusServiceUnavailable, err.Error())
}
var code string
if err := db.QueryRow("SELECT shortcode FROM shortcodes WHERE query = ?", query).Scan(&code); err == nil {
return htmlR(code) // the same query always gets the same code
}
code, err = insertID(db, "INSERT INTO shortcodes (shortcode, query, creation_date) VALUES (?, ?, ?)", query, stateNow())
if err != nil {
lg.Error("shortcode insert: %v", err)
return errJSON(http.StatusInternalServerError, "Could not create a shortcode")
}
lg.Info("Generated shortcode %s for query '%s'", code, query)
return htmlR(code)
}
func (a *App) handleShortcode(code string) Resp {
clean := cleanInput(code, idChars)
if clean == "" {
return htmlR("Empty shortcode.")
}
db, err := a.stateDB()
if err != nil {
return errJSON(http.StatusServiceUnavailable, err.Error())
}
var query string
if err := db.QueryRow("SELECT query FROM shortcodes WHERE shortcode = ?", clean).Scan(&query); err != nil {
return a.notFound()
}
lg.Info("Resolved shortcode %s for query '%s'", clean, query)
return Resp{Status: http.StatusFound, Type: "text/html; charset=utf-8",
Header: map[string]string{"Location": "/find?kw=" + url.QueryEscape(query) + "&view=rich"}}
}
func (a *App) notFound() Resp {
return jsonStatus(map[string]string{
"error": "You're lost! Isaiah 41:10 - Fear thou not; for I am with thee: be not dismayed; for I am thy God: I will strengthen thee; yea, I will help thee; yea, I will uphold thee with the right hand of my righteousness."},
http.StatusNotFound)
}
func (a *App) tableReport(title, id, query string, header bool) Resp {
db, err := a.stateDB()
if err != nil {
return errJSON(http.StatusServiceUnavailable, err.Error())
}
rows, err := db.Query(query)
if err != nil {
lg.Error("report: %v", err)
return errJSON(http.StatusInternalServerError, "Database error")
}
defer rows.Close()
var sb strings.Builder
fmt.Fprintf(&sb, "<h2>%s</h2><div class=\"table-wrap\"><table id=\"%s\">", title, id)
if header {
sb.WriteString("<tr><td>shortcode</td><td>query</td><td>creation_date</td></tr>")
}
for rows.Next() {
var c1, c2, c3 sql.NullString
rows.Scan(&c1, &c2, &c3)
fmt.Fprintf(&sb, "<tr><td>%s</td><td>%s</td><td>%s</td></tr>", html.EscapeString(c1.String), html.EscapeString(c2.String), html.EscapeString(c3.String))
}
sb.WriteString("</table></div>")
return htmlR(a.staticHTMLDoc(title, sb.String()))
}
// ---------------------------------------------------------------------------
// /seq : hand out scripture a few verses at a time
func (a *App) purgeSequences(db *sql.DB) {
rows, err := db.Query("SELECT uid, last_used FROM reading_tracker LIMIT 10")
if err != nil {
return
}
var stale []string
for rows.Next() {
var uid, last string
rows.Scan(&uid, &last)
if t, err := time.ParseInLocation(stateTimeFormat, last, time.Local); err == nil && time.Since(t) > seqPurgeAfter {
stale = append(stale, uid)
} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", last, time.Local); err == nil && time.Since(t) > seqPurgeAfter {
stale = append(stale, uid)
}
}
rows.Close()
for _, uid := range stale {
lg.Info("Purging UID: %s", uid)
db.Exec("DELETE FROM reading_tracker WHERE uid = ?", uid)
}
}
func (a *App) startIndex(ref string) (int, bool) {
ref = strings.TrimSpace(ref)
if ref == "" {
return 0, true
}
resolved := a.res.Resolve(ref, true)
if resolved == nil {
return 0, false
}
i := a.store.VerseIndex(resolved.Refs[0].Start)
return i, i >= 0
}
func (a *App) handleSeq(q url.Values) Resp {
uid, getuid := q.Get("uid"), q.Get("getuid") != ""
num := 1
if q.Has("num") {
n, err := strconv.Atoi(q.Get("num"))
if err != nil {
return htmlR("Invalid num argument")
}
num = n
}
if uid == "" && !getuid {
return a.staticPage("html/seq.html")
}
num-- // 0 will mean "next"
num = min(num, len(a.store.verseIDs))
if num < 0 {
return jsonR(map[string]string{"error": "You may only increment forward."})
}
db, err := a.stateDB()
if err != nil {
return errJSON(http.StatusServiceUnavailable, err.Error())
}
if getuid {
start, ok := a.startIndex(q.Get("start"))
if !ok {
return jsonR(map[string]string{"error": "Could not find that starting verse."})
}
id, err := insertID(db, "INSERT INTO reading_tracker VALUES(?, ?, ?)", start, stateNow())
if err != nil {
lg.Error("UID database failed to insert: %v", err)
return jsonR(map[string]string{"error": "Something went wrong with the UID database. Please notify tyler@dinsmoor.us"})
}
lg.Info("New SEQ UID: %s", id)
return htmlR("Your new UID is: " + id)
}
uid = cleanInput(strings.ReplaceAll(uid, " ", ""), searchChars[:62]+":-,")
var next int
if err := db.QueryRow("SELECT verse FROM reading_tracker WHERE uid = ?", uid).Scan(&next); err != nil {
lg.Info("Invalid UID %s", uid)
return jsonR(map[string]string{"error": "UID Invalid or purged (90 days of inactivity)"})
}
a.purgeSequences(db)
total := len(a.store.verseIDs)
if next >= total || next < 0 { // finished the Bible: start over
next = 0
}
last := min(next+num, total-1)
verses := a.store.Range(a.store.verseIDs[next], a.store.verseIDs[last])
if _, err := db.Exec("REPLACE INTO reading_tracker (uid, verse, last_used) VALUES(?, ?, ?)", uid, last+1, stateNow()); err != nil {
lg.Error("seq update: %v", err)
}
lg.Info("Served %d verses to UID %s", last-next+1, uid)
return a.render(verses, viewOf(q, "json"), "").resp()
}
// ---------------------------------------------------------------------------
// /plan : a /seq style tracker that can go backwards. The plan is its UID; the
// page URL (/plan/<uid>?n=<verses per day>) is what gets bookmarked.
func planPerDay(raw string, def int) int {
n, err := strconv.Atoi(raw)
if err != nil {
return def
}
return max(1, min(planMaxPerDay, n))
}
func (a *App) planError(msg string) Resp {
r := htmlR(a.staticHTMLDoc("Reading Plan",
"<h2>Reading Plan</h2><p>"+html.EscapeString(msg)+"</p><p><a href='/plan'>Back to plan setup</a></p>"))
r.Status = http.StatusBadRequest
return r
}
func redirect303(to string) Resp {
return Resp{Status: http.StatusSeeOther, Type: "text/html; charset=utf-8", Header: map[string]string{"Location": to}}
}
func validPlanUID(uid string) bool { return uid != "" && cleanInput(uid, idChars) == uid }
func (a *App) handlePlanNew(f url.Values) Resp {
db, err := a.stateDB()
if err != nil {
return errJSON(http.StatusServiceUnavailable, err.Error())
}
total := len(a.store.verseIDs)
start, ok := a.startIndex(f.Get("start"))
if !ok {
return a.planError("We couldn't find that starting point. Try something like 'Matthew 1:1'.")
}
var perDay int
if f.Get("pace") == "verses" {
perDay = planPerDay(f.Get("verses"), 25)
} else {
days, err := strconv.Atoi(f.Get("days"))
if err != nil {
days = 365
}
days = max(1, min(3650, days))
perDay = planPerDay(strconv.Itoa(int(math.Ceil(float64(total-start)/float64(days)))), 25)
}
uid, err := insertID(db, "INSERT INTO reading_tracker VALUES(?, ?, ?)", start, stateNow())
if err != nil {
return a.planError("Could not create a plan, please try again.")
}
lg.Info("New reading plan %s start=%d per_day=%d", uid, start, perDay)
return redirect303(fmt.Sprintf("/plan/%s?n=%d", uid, perDay))
}
func (a *App) planPosition(uid string) (int, bool, error) {
db, err := a.stateDB()
if err != nil {
return 0, false, err
}
var pos int
err = db.QueryRow("SELECT verse FROM reading_tracker WHERE uid = ?", uid).Scan(&pos)
return pos, err == nil, nil
}
func (a *App) handlePlanMove(uid string, f url.Values) Resp {
perDay := planPerDay(f.Get("n"), 25)
if !validPlanUID(uid) {
return a.planError("That reading plan id is not valid.")
}
pos, found, err := a.planPosition(uid)
if err != nil {
return errJSON(http.StatusServiceUnavailable, err.Error())
}
if !found {
return a.planError("That reading plan was not found (plans unused for 90 days are removed).")
}
total := len(a.store.verseIDs)
switch f.Get("action") {
case "next":
pos = min(total, pos+perDay)
case "back":
pos = max(0, pos-perDay)
case "restart":
pos = 0
case "goto":
target, ok := a.startIndex(f.Get("kw"))
if !ok {
return a.planError("We couldn't find that reference. Try something like 'John 3:1'.")
}
pos = target
}
a.store.db.Exec("REPLACE INTO reading_tracker (uid, verse, last_used) VALUES(?, ?, ?)", uid, pos, stateNow())
return redirect303(fmt.Sprintf("/plan/%s?n=%d", uid, perDay))
}
func (a *App) handlePlanView(uid string, q url.Values) Resp {
if !validPlanUID(uid) {
return a.planError("That reading plan id is not valid.")
}
perDay := planPerDay(q.Get("n"), 25)
position, found, err := a.planPosition(uid)
if err != nil {
return errJSON(http.StatusServiceUnavailable, err.Error())
}
if !found {
return a.planError("That reading plan was not found (plans unused for 90 days are removed).")
}
// keeps an active plan from being purged as unused
a.store.db.Exec("REPLACE INTO reading_tracker (uid, verse, last_used) VALUES(?, ?, ?)", uid, position, stateNow())
ids := a.store.verseIDs
total := len(ids)
finished := position >= total
lastIndex := min(total, position+perDay) - 1
percent := math.Round(1000*float64(min(position, total))/float64(total)) / 10
esc := html.EscapeString
controls := fmt.Sprintf("<form method='post' action='/plan/%s/move' class='plan-form'>\n<input type='hidden' name='n' value='%d'>", uid, perDay)
backDisabled := ""
if position <= 0 {
backDisabled = "disabled"
}
next := ""
if !finished {
next = "<button type='submit' name='action' value='next' class='plan-primary'>Done &mdash; next reading &rarr;</button>"
}
controls += fmt.Sprintf(`%s
<button type='submit' name='action' value='back' class='plan-secondary' %s>&larr; Previous reading</button>
</form>
<form method='post' action='/plan/%s/move' class='plan-form plan-goto'>
<input type='hidden' name='n' value='%d'>
<input type='hidden' name='action' value='goto'>
<label for='goto-kw'>Jump to</label>
<div class='plan-goto-row'><input type='text' id='goto-kw' name='kw' data-book-suggest placeholder='e.g. Psalms 23:1' autocapitalize='off' autocomplete='off'>
<button type='submit' class='plan-secondary'>Go</button></div>
</form>`, next, backDisabled, uid, perDay)
var summary, versesHTML string
if finished {
summary = fmt.Sprintf("<div class='plan-summary'><strong>You've finished the plan!</strong><div>You read all %s verses. Use Previous reading to look back, or start over.</div></div>", commas(total))
controls += fmt.Sprintf("<form method='post' action='/plan/%s/move' class='plan-form'>\n<input type='hidden' name='n' value='%d'>\n<button type='submit' name='action' value='restart' class='plan-secondary'>Start over from Genesis 1:1</button></form>", uid, perDay)
} else {
first, last := a.store.FormatRef(ids[position]), a.store.FormatRef(ids[lastIndex])
summary = fmt.Sprintf("<div class='plan-summary'><strong>Today's reading</strong><div class='plan-range'>%s &ndash; %s</div>"+
"<div class='plan-meta'>%d verses &middot; %.1f%% of the Bible complete</div>"+
"<div class='plan-progress' role='img' aria-label='%.1f%% complete'><span style='width:%.1f%%'></span></div></div>",
esc(first), esc(last), lastIndex-position+1, percent, percent, percent)
var sb strings.Builder
for _, v := range a.store.Range(ids[position], ids[lastIndex]) {
sb.WriteString(a.responseRich(v))
}
versesHTML = sb.String()
}
note := "<div class='plan-note'>Bookmark this page &mdash; it is your reading plan. Come back each day, then press <em>Done</em> to move on.</div>"
store := fmt.Sprintf("<script>try{localStorage.setItem('kjv.plan',JSON.stringify({uid:'%s',n:%d,t:Date.now()}))}catch(e){}</script>", uid, perDay)
return htmlR(a.renderNavbar("", nil, "") + "<div class='responses'>" + summary + versesHTML +
"<div class='plan-controls'>" + controls + "</div>" + note + store + "</div>")
}
func commas(n int) string {
s := strconv.Itoa(n)
for i := len(s) - 3; i > 0; i -= 3 {
s = s[:i] + "," + s[i:]
}
return s
}
// ---------------------------------------------------------------------------
// abuse limits
// creationLimiter bounds how fast each visitor can add rows (shortcodes,
// plans, sequences), with a looser limit over everyone together as a backstop.
type creationLimiter struct {
mu sync.Mutex
clients map[string]*bucket
all bucket
}
type bucket struct {
tokens float64
last time.Time
}
const (
clientRate, clientBurst = 0.5, 10.0 // per visitor: a new row every 2 seconds, 10 at once
totalRate, totalBurst = 20.0, 100.0
maxTrackedClients = 10000
)
var creations = &creationLimiter{clients: map[string]*bucket{}, all: bucket{totalBurst, time.Now()}}
func (b *bucket) take(now time.Time, rate, burst float64) bool {
b.tokens = min(burst, b.tokens+now.Sub(b.last).Seconds()*rate)
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func (c *creationLimiter) allow(client string) bool {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
if len(c.clients) > maxTrackedClients { // forget visitors who have long since refilled
for k, b := range c.clients {
if now.Sub(b.last) > time.Minute {
delete(c.clients, k)
}
}
}
if isLoopback(client) { // the proxy did not say who this is: only the shared limit applies
return c.all.take(now, totalRate, totalBurst)
}
b := c.clients[client]
if b == nil {
b = &bucket{clientBurst, now}
c.clients[client] = b
}
if !b.take(now, clientRate, clientBurst) {
return false
}
return c.all.take(now, totalRate, totalBurst)
}
// clientAddr is the visitor as the reverse proxy saw them. Only the proxy can
// reach this program (it listens on localhost), so the header it sets is
// trusted; the last X-Forwarded-For entry is the one the proxy itself added.
func clientAddr(r *http.Request) string {
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" {
return ip
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
noClientHeader.Do(func() {
lg.Warn("requests arrive without X-Real-IP or X-Forwarded-For: the reverse proxy should pass the visitor's address on, " +
"otherwise creating plans and shortcodes is limited for all visitors together, not for each")
})
return host
}
var noClientHeader sync.Once
func isLoopback(addr string) bool {
ip := net.ParseIP(addr)
return addr == "" || (ip != nil && ip.IsLoopback())
}
func tooMany() Resp {
r := errJSON(http.StatusTooManyRequests, "Too many requests, try again in a moment")
r.Header = map[string]string{"Retry-After": "5"}
return r
}
// maintain deletes reading plans nobody has used for 90 days (the pages promise this).
func (a *App) maintain() {
for {
if a.store.stateOK {
cutoff := time.Now().Add(-seqPurgeAfter).Format(stateTimeFormat)
if res, err := a.store.db.Exec("DELETE FROM reading_tracker WHERE last_used < ?", cutoff); err == nil {
if n, _ := res.RowsAffected(); n > 0 {
lg.Info("Purged %d unused reading plans", n)
}
}
}
time.Sleep(6 * time.Hour)
}
}
// adminOnly protects the listings of every plan id and shortcode (a plan id is
// all that is needed to move that plan). They need the KJV_SECRET_API_KEY as a
// bearer token, and do not exist when no key is set.
func (a *App) adminOnly(r *http.Request) (Resp, bool) {
if a.secretKey == "" {
return a.notFound(), false
}
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(a.secretKey)) != 1 {
resp := errJSON(http.StatusUnauthorized, "Invalid Authorization")
resp.Header = map[string]string{"WWW-Authenticate": "Bearer"}
return resp, false
}
return Resp{}, true
}
+227
View File
@@ -0,0 +1,227 @@
package kjv
import (
"database/sql"
"fmt"
"sort"
_ "modernc.org/sqlite"
)
// Verse is one row of the fts_kjv table. ID is book*1_000_000 + chapter*1000 + verse.
type Verse struct {
ID int64
Book int
Chapter int
Verse int
Text string
}
type BookInfo struct {
Title string
Category string
OTNT string
Chapters int
}
// Store holds the companion database plus everything read from it at startup.
// The whole text is only ~4MB, so verse lookups never touch SQLite; the FTS
// index is still queried for keyword search.
type Store struct {
db *sql.DB
verses []Verse // sorted by ID
verseIDs []int64
chapterList []int64 // chapter keys (ID/1000), sorted and unique
bookNames []string
bookName map[int]string
bookByName map[string]int
chapterLen map[int]int
bookInfo []BookInfo
abbrevs []abbrev
stateOK bool // reading plans and shortcodes can be written
}
type abbrev struct {
Name string
Book int
}
func OpenStore(path string) (*Store, error) {
dsn := "file:" + path + "?_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
s := &Store{db: db, bookName: map[int]string{}, bookByName: map[string]int{}, chapterLen: map[int]int{}}
if err := s.load(); err != nil {
db.Close()
return nil, fmt.Errorf("%s: %w", path, err)
}
s.stateOK = s.initState() == nil
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) load() error {
rows, err := s.db.Query("SELECT b, n FROM key_english ORDER BY b")
if err != nil {
return err
}
for rows.Next() {
var id int
var name string
if err := rows.Scan(&id, &name); err != nil {
rows.Close()
return err
}
s.bookNames = append(s.bookNames, name)
s.bookName[id] = name
s.bookByName[name] = id
}
rows.Close()
if len(s.bookNames) == 0 {
return fmt.Errorf("key_english is empty")
}
rows, err = s.db.Query("SELECT id, b, c, v, t FROM fts_kjv ORDER BY id")
if err != nil {
return err
}
for rows.Next() {
var v Verse
if err := rows.Scan(&v.ID, &v.Book, &v.Chapter, &v.Verse, &v.Text); err != nil {
rows.Close()
return err
}
s.verses = append(s.verses, v)
}
rows.Close()
if len(s.verses) == 0 {
return fmt.Errorf("fts_kjv is empty")
}
for _, v := range s.verses {
s.verseIDs = append(s.verseIDs, v.ID)
if key := v.ID / 1000; len(s.chapterList) == 0 || s.chapterList[len(s.chapterList)-1] != key {
s.chapterList = append(s.chapterList, key)
}
if v.Chapter > s.chapterLen[v.Book] {
s.chapterLen[v.Book] = v.Chapter
}
}
rows, err = s.db.Query(`SELECT title_short, category, otnt, chapters FROM book_info ORDER BY "order"`)
if err != nil {
return err
}
for rows.Next() {
var b BookInfo
if err := rows.Scan(&b.Title, &b.Category, &b.OTNT, &b.Chapters); err != nil {
rows.Close()
return err
}
s.bookInfo = append(s.bookInfo, b)
}
rows.Close()
rows, err = s.db.Query("SELECT a, b FROM key_abbreviations_english ORDER BY id")
if err != nil {
return err
}
for rows.Next() {
var a abbrev
if err := rows.Scan(&a.Name, &a.Book); err != nil {
rows.Close()
return err
}
s.abbrevs = append(s.abbrevs, a)
}
rows.Close()
return nil
}
// initState creates the tables that hold user-generated data (reading plans and
// shortcodes) inside the companion database.
func (s *Store) initState() error {
for _, stmt := range []string{
"CREATE TABLE IF NOT EXISTS reading_tracker (uid TEXT PRIMARY KEY, verse INTEGER, last_used TEXT)",
"CREATE TABLE IF NOT EXISTS shortcodes (shortcode TEXT PRIMARY KEY, query TEXT UNIQUE, creation_date TEXT)",
} {
if _, err := s.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) BookName(id int) string { return s.bookName[id] }
func (s *Store) FormatRef(verseID int64) string {
return fmt.Sprintf("%s %d:%d", s.BookName(int(verseID/1_000_000)), verseID/1000%1000, verseID%1000)
}
// Range returns the verses with start <= id <= end (end <= 0 means just start).
func (s *Store) Range(start, end int64) []Verse {
i := sort.Search(len(s.verses), func(i int) bool { return s.verses[i].ID >= start })
if end <= 0 {
if i < len(s.verses) && s.verses[i].ID == start {
return s.verses[i : i+1]
}
return nil
}
j := sort.Search(len(s.verses), func(j int) bool { return s.verses[j].ID > end })
if i >= j {
return nil
}
return s.verses[i:j]
}
// VerseIndex is the position of a verse id in the whole Bible, or -1.
func (s *Store) VerseIndex(id int64) int {
i := sort.Search(len(s.verseIDs), func(i int) bool { return s.verseIDs[i] >= id })
if i < len(s.verseIDs) && s.verseIDs[i] == id {
return i
}
return -1
}
// FTS runs a full text query and returns the matching verses in Bible order.
func (s *Store) FTS(query string) ([]Verse, error) {
rows, err := s.db.Query("SELECT id FROM fts_kjv(?)", query)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Verse
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
if v := s.Range(id, 0); len(v) == 1 {
out = append(out, v[0])
}
}
return out, rows.Err()
}
type crossRef struct{ Start, End int64 }
func (s *Store) CrossReferences(verseID int64) ([]crossRef, error) {
rows, err := s.db.Query("SELECT sv, ev FROM cross_reference WHERE vid = ? ORDER BY r DESC LIMIT 15", verseID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []crossRef
for rows.Next() {
var c crossRef
if err := rows.Scan(&c.Start, &c.End); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}