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 := "

Books

" for _, n := range a.store.bookNames { body += fmt.Sprintf("", urlQuery(n), htmlEsc(n)) } return htmlR(a.staticHTMLDoc("Books", body+"
%s
")) }) get("/status", func(r *http.Request) Resp { var m runtime.MemStats runtime.ReadMemStats(&m) return htmlR(a.staticHTMLDoc("Status", fmt.Sprintf("

Status

Sanity: %v

Uptime: %s

Memory: %d KB

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