recipe-card/handler.go
2017-07-08 01:17:01 -04:00

535 lines
13 KiB
Go

package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"html/template"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/blevesearch/bleve"
blevemapping "github.com/blevesearch/bleve/mapping"
log "github.com/sirupsen/logrus"
"github.com/tblyler/recipe-card/recipe"
)
const (
imagePattern = "/images/"
stockImagePatten = "/stock-images/"
recipePattern = "/recipe/"
docxPattern = "/docx/"
)
// Handler contains functions for http handlerfunc
type Handler struct {
recipePath string
recipes map[string]*recipe.Recipe
recipeSlice []*recipe.Recipe
idx bleve.Index
templates *template.Template
logger *log.Logger
}
func GetItemIndex(path string) (map[string][]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
reader := bufio.NewReader(file)
itemIndex := make(map[string][]byte)
for {
data, err := reader.ReadBytes('\n')
if err != nil {
if err == io.EOF {
return itemIndex, nil
}
return nil, err
}
// remove newline
key := string(data[:len(data)-1])
sha256sum := make([]byte, sha256.Size)
read, err := reader.Read(sha256sum)
if err != nil && read != sha256.Size {
return nil, err
}
itemIndex[key] = sha256sum
if err == io.EOF {
break
}
}
return itemIndex, nil
}
func SaveItemIndex(itemIndex map[string][]byte, path string) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
for key, sha256sum := range itemIndex {
_, err = file.WriteString(key + "\n")
if err != nil {
return err
}
_, err = file.Write(sha256sum)
if err != nil {
return err
}
}
return nil
}
// NewHandler creates a new instance to handle HTTP requests
func NewHandler(recipePath string, indexPath string, logger *log.Logger) (*Handler, error) {
if logger == nil {
logger = log.New()
logger.Out = ioutil.Discard
}
logger.WithField("recipePath", recipePath).Debugln("Getting absolute recipe path")
var err error
recipePath, err = filepath.Abs(recipePath)
if err != nil {
return nil, fmt.Errorf("Failed absolute recipe path: %s", err.Error())
}
logger.WithField("recipePath", recipePath).Debugln("Got absolute recipe path")
if indexPath != "" {
logger.WithField("indexPath", indexPath).Debugln("Getting absolute index path")
indexPath, err = filepath.Abs(indexPath)
if err != nil {
return nil, fmt.Errorf("Failed absolute index path: %s", err.Error())
}
logger.WithField("indexPath", indexPath).Debugln("Got absolute index path")
os.MkdirAll(indexPath, 0755)
}
logger.WithField("recipePath", recipePath).Infoln("Getting recipes from path")
recipeSlice, err := recipe.RecipesFromPath(recipePath)
if err != nil {
return nil, err
}
logger.Infof("Found %d recipes", len(recipeSlice))
handler := new(Handler)
handler.logger = logger
handler.recipePath = recipePath
handler.recipeSlice = recipeSlice
handler.recipes = make(map[string]*recipe.Recipe)
bleveIndexPath := ""
itemIndexPath := ""
// this improves indexing performance a shit ton
// I don't think it stores the document data, just analysis data
// could be wrong, documentation is sparse for it
// functionality seems the same for here though
blevemapping.StoreDynamic = false
if indexPath == "" {
logger.Info("Creating memory mapped search index")
handler.idx, err = bleve.NewMemOnly(bleve.NewIndexMapping())
} else {
itemIndexPath = filepath.Join(indexPath, "item.idx")
bleveIndexPath = filepath.Join(indexPath, "bleve")
logger.WithField("bleveIndexPath", bleveIndexPath).Infoln("Trying to open index path")
handler.idx, err = bleve.Open(bleveIndexPath)
if err != nil {
logger.WithError(err).WithField("bleveIndexPath", bleveIndexPath).Warnln(
"Failed to open index path, trying to recreate it",
)
handler.idx, err = bleve.New(bleveIndexPath, bleve.NewIndexMapping())
}
}
if err != nil {
return nil, fmt.Errorf("Bleve open: %s", err.Error())
}
var itemIndex map[string][]byte
if itemIndexPath != "" {
logger.WithField("itemIndexPath", itemIndexPath).Debugln("Trying to open previous item index")
itemIndex, err = GetItemIndex(itemIndexPath)
if err != nil {
logger.WithError(err).WithField("itemIndexPath", itemIndexPath).Warnln("Failed to open previous item index")
} else {
logger.WithField("count", len(itemIndex)).Debugln("Got previous item indexes")
}
}
if itemIndex == nil {
itemIndex = make(map[string][]byte)
}
for _, recip := range recipeSlice {
if recip.Title == "" {
logger.WithField("docx", recip.DocxPath).Errorln(
"Missing title",
)
continue
}
if oldRecip, exists := handler.recipes[recip.Title]; exists {
logger.WithFields(log.Fields{
"existingPath": oldRecip.DocxPath,
"newPath": recip.DocxPath,
"title": recip.Title,
}).Errorln("Duplicate recipe title")
continue
}
handler.recipes[recip.Title] = recip
logger.WithField("recipeTitle", recip.Title).Debugln("Hashing data")
hasher := sha256.New()
io.WriteString(hasher, recip.Title)
for _, order := range recipe.ValidCategoriesOrder {
if info, exists := recip.Info[order]; exists {
for _, line := range info {
io.WriteString(hasher, line)
}
}
}
sha256sum := hasher.Sum(nil)
logger.WithFields(log.Fields{
"recipeTitle": recip.Title,
"sha256": hex.EncodeToString(sha256sum),
}).Debugln("Finished hashing data")
if oldSha, exists := itemIndex[recip.Title]; !exists || !bytes.Equal(sha256sum, oldSha) {
logger.WithFields(log.Fields{
"recipeTitle": recip.Title,
"docx": recip.DocxPath,
}).Infoln("Indexing")
if exists {
handler.idx.Delete(recip.Title)
}
itemIndex[recip.Title] = sha256sum
err = handler.idx.Index(recip.Title, recip)
if err != nil {
return nil, fmt.Errorf("Index fail: %s", err.Error())
}
logger.WithField("recipeTitle", recip.Title).Infoln("Indexed")
}
}
for recipeTitle := range itemIndex {
if _, exists := handler.recipes[recipeTitle]; !exists {
logger.WithField("recipeTitle", recipeTitle).Infoln("Removing missing recipe")
handler.idx.Delete(recipeTitle)
delete(itemIndex, recipeTitle)
}
}
err = SaveItemIndex(itemIndex, itemIndexPath)
if err != nil {
logger.WithError(err).WithField("itemIndexPath", itemIndexPath).Warnln(
"Failed to update index data",
)
} else {
logger.Infoln("Updated index data")
}
handler.templates, err = NewTemplate(logger)
if err != nil {
return nil, err
}
return handler, nil
}
// Close handler and free up memory
func (h *Handler) Close() error {
h.recipes = nil
return h.idx.Close()
}
// GetHandlerFuncs in a pattern->func map
func (h *Handler) GetHandlerFuncs() map[string]http.HandlerFunc {
return map[string]http.HandlerFunc{
"/": h.Index,
"/search/": h.Search,
"/recipes/": h.Recipes,
recipePattern: h.Recipe,
"/css/mini.css": h.MiniCSS,
"/css/main.css": h.MainCSS,
imagePattern: h.Images,
stockImagePatten: h.StockImages,
docxPattern: h.Docx,
}
}
// MainCSS outputs the main css file information
func (h *Handler) MainCSS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
io.WriteString(w, maincss)
}
// MiniCSS writes the mini css data to writer
func (h *Handler) MiniCSS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css")
io.WriteString(w, minicss)
}
// Index handles index request
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
tmplData := &TemplateData{
PageTitle: "Recipe Card",
}
h.templates.ExecuteTemplate(w, "index", tmplData)
}
// Search handles search request
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
search := strings.TrimSpace(r.PostFormValue("search"))
if search == "" {
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
tmplData := &TemplateData{
PageTitle: "Recipe Card - Search",
SearchValue: search,
}
searchResults, _ := h.idx.Search(bleve.NewSearchRequest(bleve.NewMatchQuery(
search,
)))
// try a fuzzy search if matchquery fails
if searchResults.Hits.Len() == 0 {
searchResults, _ = h.idx.Search(bleve.NewSearchRequest(bleve.NewFuzzyQuery(
search,
)))
}
for _, hit := range searchResults.Hits {
recipe := h.recipes[hit.ID]
tmplData.Recipes = append(
tmplData.Recipes,
h.recipeToTemplateRecipe(recipe),
)
}
h.templates.ExecuteTemplate(w, "search", tmplData)
}
// Recipes handles recipes page for all recipes
func (h *Handler) Recipes(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
tmplData := &TemplateData{
PageTitle: "Recipe Card - Recipes",
}
for _, recipe := range h.recipeSlice {
tmplData.Recipes = append(
tmplData.Recipes,
h.recipeToTemplateRecipe(recipe),
)
}
h.templates.ExecuteTemplate(w, "recipes", tmplData)
}
// Recipe handles a single recipe page
func (h *Handler) Recipe(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
id := strings.TrimPrefix(r.URL.Path, recipePattern)
if recipe, exists := h.recipes[id]; exists {
tmplData := &TemplateData{
PageTitle: "Recipe Card - " + id,
}
tmplData.Recipes = append(
tmplData.Recipes,
h.recipeToTemplateRecipe(recipe),
)
h.templates.ExecuteTemplate(w, "recipe", tmplData)
return
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
// StockImages handles all stock image requests
func (h *Handler) StockImages(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
id := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, ".jpg"), stockImagePatten)
if recipe, exists := h.recipes[id]; exists {
w.Write(recipe.Image)
return
}
w.WriteHeader(http.StatusNotFound)
return
}
// Docx handles all docx download requests
func (h *Handler) Docx(w http.ResponseWriter, r *http.Request) {
lowerPath := strings.ToLower(r.URL.Path)
if !strings.HasSuffix(lowerPath, "docx") {
w.WriteHeader(http.StatusNotFound)
return
}
file, err := os.Open(h.urlToPath(r.URL.Path, docxPattern))
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
defer file.Close()
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
io.Copy(w, file)
}
// Images handles all image requests
func (h *Handler) Images(w http.ResponseWriter, r *http.Request) {
lowerPath := strings.ToLower(r.URL.Path)
if !strings.HasSuffix(lowerPath, "jpg") && !strings.HasSuffix(lowerPath, "jpeg") {
w.WriteHeader(http.StatusNotFound)
return
}
file, err := os.Open(h.urlToPath(r.URL.Path, imagePattern))
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
defer file.Close()
w.Header().Set("Content-Type", "image/jpeg")
io.Copy(w, file)
}
func (h *Handler) pathToURL(filePath, pattern string) (string, error) {
path, err := filepath.Rel(h.recipePath, filePath)
if err != nil {
return "", err
}
urlPath := pattern[:len(pattern)-1]
for _, pathPart := range strings.Split(path, string(filepath.Separator)) {
if pathPart == "" {
continue
}
urlPath += "/" + url.PathEscape(pathPart)
}
return urlPath, nil
}
func (h *Handler) urlToPath(url, pattern string) string {
path := filepath.Join(
h.recipePath,
strings.Replace(strings.TrimPrefix(url, pattern), "/", string(filepath.Separator), -1),
)
if !filepath.IsAbs(path) {
log.WithFields(log.Fields{
"url": url,
"pattern": pattern,
"path": path,
}).Errorln("Must only receive absolute path")
return ""
}
return path
}
// recipeToTemplateRecipe converts a recipe.Recipe to a TemplateRecipe
func (h *Handler) recipeToTemplateRecipe(rec *recipe.Recipe) *TemplateRecipe {
tmplRecipe := &TemplateRecipe{
ID: rec.Title,
URL: "/recipe/" + url.PathEscape(rec.Title),
StockImage: stockImagePatten + url.PathEscape(rec.Title+".jpg"),
}
docxURL, err := h.pathToURL(rec.DocxPath, docxPattern)
if err != nil {
log.WithError(err).WithField("docxPath", rec.DocxPath).Warnln(
"Failed to get docx url",
)
} else {
tmplRecipe.DocxURL = docxURL
}
for _, imagePath := range rec.ScanPaths {
urlPath, err := h.pathToURL(imagePath, imagePattern)
if err != nil {
continue
}
tmplRecipe.Images = append(
tmplRecipe.Images,
urlPath,
)
}
for _, category := range recipe.ValidCategoriesOrder {
if info, exists := rec.Info[category]; exists {
description := ""
for _, infoLine := range info {
description += "<p>" + html.EscapeString(infoLine) + "</p>"
}
tmplRecipe.Description += template.HTML(fmt.Sprintf(
"<h3>%s</h3>%s",
html.EscapeString(category),
description,
))
}
}
return tmplRecipe
}