Diffstat (limited to 'internal')
| -rw-r--r-- | internal/factgiver/factgiver.go | 67 | ||||
| -rw-r--r-- | internal/frontmatter/parser.go | 2 | ||||
| -rw-r--r-- | internal/router/api-v1-blog-search.go | 18 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-body.go | 16 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-bottom-embeds.go | 4 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-footer.go | 4 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-header.go | 5 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-top-embeds.go | 4 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page.go | 52 | ||||
| -rw-r--r-- | internal/router/client-cache.go | 164 | ||||
| -rw-r--r-- | internal/templatemanager/templatemanager.go | 19 |
11 files changed, 298 insertions, 57 deletions
diff --git a/internal/factgiver/factgiver.go b/internal/factgiver/factgiver.go new file mode 100644 index 0000000..6f77403 --- /dev/null +++ b/internal/factgiver/factgiver.go @@ -0,0 +1,67 @@ +package factgiver + +import ( + "fmt" + "math/rand" + "regexp" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/b2" +) + +type FactGiver struct { + b2Client *b2.B2Client + cache map[string][]string + langs []string + factsFileName string + nlRe *regexp.Regexp + randGen *rand.Rand +} + +func NewFactGiver(cfg *config.FactGiverConfig, langs []string) (*FactGiver, error) { + b2Client, err := b2.NewB2Client(&cfg.Storage.Config) + if err != nil { + return nil, fmt.Errorf("fail to init b2 client for a new fact giver: %s", err.Error()) + } + + factGiver := &FactGiver{ + b2Client: b2Client, + cache: make(map[string][]string, len(langs)), + langs: langs, + factsFileName: cfg.FactsFileName, + nlRe: regexp.MustCompile(`\r?\n`), + randGen: rand.New(rand.NewSource(time.Now().UnixNano())), + } + if err = factGiver.initCache(); err != nil { + return nil, fmt.Errorf("fail to init cache for a new fact giver: %s", err.Error()) + } + + return factGiver, nil +} + +func (g *FactGiver) Give(lang string) [3]string { + factSlice := make([]string, len(g.cache[lang])) + copy(factSlice, g.cache[lang]) + g.randGen.Shuffle(len(factSlice), func(i, j int) { + factSlice[i], factSlice[j] = factSlice[j], factSlice[i] + }) + return [3]string{factSlice[0], factSlice[1], factSlice[2]} +} + +func (g *FactGiver) initCache() error { + for _, lang := range g.langs { + localFacts := strings.Replace(g.factsFileName, "*", lang, 1) + factsContentBytes, err := g.b2Client.ReadAll(localFacts) + if err != nil { + return fmt.Errorf("fail to read '%s' facts file: %s", lang, err.Error()) + } + factsContent := string(factsContentBytes) + g.cache[lang] = g.nlRe.Split(factsContent, -1) + if g.cache[lang][len(g.cache[lang])-1] == "" { + g.cache[lang] = g.cache[lang][:len(g.cache[lang])-1] + } + } + return nil +} diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go index 1a521c0..83312dc 100644 --- a/internal/frontmatter/parser.go +++ b/internal/frontmatter/parser.go @@ -16,6 +16,8 @@ type Metadata struct { Thumbnail string `yaml:"thumbnail"` Tags []string `yaml:"tags"` Geolocation string `yaml:"geolocation"` + Medley string `yaml:"medley"` + MedleyPart int `yaml:"medleyPart"` } func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) { diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go index e22fabe..69be651 100644 --- a/internal/router/api-v1-blog-search.go +++ b/internal/router/api-v1-blog-search.go @@ -1,6 +1,7 @@ package router import ( + "encoding/json" "fmt" "log/slog" "net/url" @@ -35,10 +36,18 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz)) } - pages, err := b2Client.Scan(lang + "/") - if err != nil { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err)) + cacheKey := "blog-search." + lang + ".pages-list" + var pages []*b2.BlogPage + if pagesBytes, ok := PCache.Get(cacheKey); pagesBytes != nil || ok { + json.Unmarshal(pagesBytes, &pages) + } else { + pages, err = b2Client.Scan(lang + "/") + if err != nil { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err)) + } + pagesBytes, _ := json.Marshal(pages) + PCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), 5*time.Minute) } encodedQuery := c.Request().URI().QueryString() @@ -70,6 +79,7 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie "Thumbnail": page.Metadata.Thumbnail, "Tags": page.Metadata.Tags, "LikeCount": CCache.GetLikeCount(page.FileName), + "ViewCount": CCache.GetViewCount(page.FileName), }) break } diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go index 2329c4a..b2fa268 100644 --- a/internal/router/api-v1-general-page-body.go +++ b/internal/router/api-v1-general-page-body.go @@ -4,6 +4,7 @@ import ( "fmt" "html/template" "log/slog" + "math/rand" "net/url" "regexp" "slices" @@ -11,11 +12,14 @@ import ( "time" "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/factgiver" "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" "github.com/yuin/goldmark" ) +var FactGiver *factgiver.FactGiver + func init() { tm.Add("general-page-body", "views/partials/general-page-body.html") } @@ -42,7 +46,7 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, } pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) < 2 { + if len(pathParts) == 0 { return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } @@ -108,6 +112,7 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, values["Tags"] = tagsArray values["QuerySort"] = querySort values["QueryTags"] = strings.Join(queryTags, ",") + values["Title"] = l[lang].BlogSearch.Header additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html") } else if len(pathParts) == 3 && pathParts[1] == "blog" { @@ -133,6 +138,15 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, values["ParsedMarkdown"] = template.HTML(parsedMarkdown) additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html") + + go CCache.View(c.IP(), pathParts[2]) + } else if len(pathParts) == 1 { + values["Title"] = l[lang].HomePage.Header + values["FilledHeartCount"] = uint(40) + values["OutlineHeartCount"] = uint(40) + values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1) + values["FunFacts"] = FactGiver.Give(lang) + additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") } content, err := tm.Render("general-page-body", values, additionalTemplates...) diff --git a/internal/router/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go index df766a7..c4d9543 100644 --- a/internal/router/api-v1-general-page-bottom-embeds.go +++ b/internal/router/api-v1-general-page-bottom-embeds.go @@ -38,7 +38,7 @@ func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs [] } pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) < 2 { + if len(pathParts) == 0 { return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } @@ -58,6 +58,8 @@ func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs [] additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html") } else if len(pathParts) == 3 && pathParts[1] == "blog" { additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html") + } else if len(pathParts) == 1 { + additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") } content, err := tm.Render("general-page-bottom-embeds", values, additionalTemplates...) diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go index 98da6bb..003f54c 100644 --- a/internal/router/api-v1-general-page-footer.go +++ b/internal/router/api-v1-general-page-footer.go @@ -38,7 +38,7 @@ func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []string } pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) < 2 { + if len(pathParts) == 0 { return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } @@ -58,6 +58,8 @@ func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []string additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html") } else if len(pathParts) == 3 && pathParts[1] == "blog" { additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html") + } else if len(pathParts) == 1 { + additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") } content, err := tm.Render("general-page-footer", values, additionalTemplates...) diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go index 1d65966..80228f7 100644 --- a/internal/router/api-v1-general-page-header.go +++ b/internal/router/api-v1-general-page-header.go @@ -39,7 +39,7 @@ func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []string } pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) < 2 { + if len(pathParts) == 0 { return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } @@ -67,6 +67,9 @@ func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []string values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00") values["ActionDate"] = metadata.ActionDate additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html") + } else if len(pathParts) == 1 { + values["Title"] = l[lang].HomePage.Header + additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") } content, err := tm.Render("general-page-header", values, additionalTemplates...) diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go index b26675f..78021b2 100644 --- a/internal/router/api-v1-general-page-top-embeds.go +++ b/internal/router/api-v1-general-page-top-embeds.go @@ -38,7 +38,7 @@ func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []str } pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) < 2 { + if len(pathParts) == 0 { return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") } @@ -58,6 +58,8 @@ func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []str additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html") } else if len(pathParts) == 3 && pathParts[1] == "blog" { additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html") + } else if len(pathParts) == 1 { + additionalTemplates = append(additionalTemplates, "views/pages/home-page.html") } content, err := tm.Render("general-page-top-embeds", values, additionalTemplates...) diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go new file mode 100644 index 0000000..d0ea559 --- /dev/null +++ b/internal/router/api-v1-general-page.go @@ -0,0 +1,52 @@ +package router + +import ( + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("general-page", "views/layouts/general-page.html") +} + +func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []string) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + path := c.Path() + + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 0 { + return c.Status(fiber.ErrBadRequest.Code).SendString("url path is invalid: expect format '/{lang}/...'") + } + + lang := pathParts[0] + if !slices.Contains(langs, lang) { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } + + cacheKey := "general-page." + lang + if val, ok := PCache.Get(cacheKey); val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + + content, err := tm.Render("general-page", fiber.Map{ + "L": l[lang], + "Lang": lang, + "QueryString": string(c.Request().URI().QueryString()), + }) + if err != nil { + slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") + } + + go PCache.Set(cacheKey, content, int64(len(content))) + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(content) + } +} diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index e448fb3..d607cdd 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -16,6 +16,7 @@ type ClientCache struct { hashMapMutex sync.RWMutex likePageMap map[string]map[string]struct{} + viewPageMap map[string]map[string]struct{} pageMutexMap map[string]*sync.RWMutex pageMutexMapMutex sync.Mutex @@ -38,6 +39,7 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { } likePageMap := make(map[string]map[string]struct{}) + viewPageMap := make(map[string]map[string]struct{}) pageMutexMap := make(map[string]*sync.RWMutex) for rows.Next() { @@ -50,9 +52,32 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { userIdString := base64.RawStdEncoding.EncodeToString(userId) if _, ok := likePageMap[pageRef]; !ok { likePageMap[pageRef] = make(map[string]struct{}) + viewPageMap[pageRef] = make(map[string]struct{}) pageMutexMap[pageRef] = &sync.RWMutex{} } likePageMap[pageRef][userIdString] = struct{}{} + viewPageMap[pageRef][userIdString] = struct{}{} + } + + rows, err = tx.Query("select * from blog_views;") + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err) + } + + for rows.Next() { + var pageRef string + var userId []byte + if err = rows.Scan(&pageRef, &userId); err != nil { + tx.Rollback() + return nil, fmt.Errorf("fail scanning blog_views to fill cache: %w", err) + } + userIdString := base64.RawStdEncoding.EncodeToString(userId) + if _, ok := viewPageMap[pageRef]; !ok { + viewPageMap[pageRef] = make(map[string]struct{}) + pageMutexMap[pageRef] = &sync.RWMutex{} + } + viewPageMap[pageRef][userIdString] = struct{}{} } if err = tx.Commit(); err != nil { @@ -62,6 +87,7 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { return &ClientCache{ hashMap: make(map[string]string), likePageMap: likePageMap, + viewPageMap: viewPageMap, pageMutexMap: pageMutexMap, salt: salt, db: db, @@ -74,51 +100,14 @@ func (c *ClientCache) Close() error { return fmt.Errorf("fail to init transaction with db to dump cache: %w", err) } - if _, err = tx.Exec("delete from blog_likes;"); err != nil { + if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil { tx.Rollback() - return fmt.Errorf("fail to truncate table blog_likes: %w", err) - } - - userIdBytes := make(map[string][]byte) - - sqlStatement := fmt.Sprintf(` - INSERT OR IGNORE INTO blog_likes (page_ref, user_id) - VALUES %s(?, ?); - `, strings.Repeat("(?, ?), ", 99)) - sqlStatementVars := make([]any, 0, 200) - - for pageRef, userSet := range c.likePageMap { - for userId := range userSet { - if _, ok := userIdBytes[userId]; !ok { - userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId) - if err != nil { - slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error())) - continue - } - } - - sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId])) - if len(sqlStatementVars) < 200 { - continue - } - - if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { - slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error())) - } - - sqlStatementVars = make([]any, 0, 200) - } + return fmt.Errorf("fail to save blog_likes: %s", err) } - if len(sqlStatementVars) > 0 { - sqlStatement = fmt.Sprintf(` - INSERT OR IGNORE INTO blog_likes (page_ref, user_id) - VALUES %s(?, ?); - `, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1)) - - if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { - slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error())) - } + if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil { + tx.Rollback() + return fmt.Errorf("fail to save blog_views: %s", err) } return tx.Commit() @@ -222,3 +211,94 @@ func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) { delete(c.likePageMap[page], hash) return false } + +func (c *ClientCache) GetViewStatus(id string, page string) bool { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + + if _, ok := c.viewPageMap[page]; !ok { + return false + } + _, ok := c.viewPageMap[page][c.GetHash(id)] + return ok +} + +func (c *ClientCache) GetViewCount(page string) int { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + + if userSet, ok := c.viewPageMap[page]; ok { + return len(userSet) + } + return 0 +} + +func (c *ClientCache) View(id string, page string) { + page = strings.Clone(page) + hash := c.GetHash(id) + + mutex := c.getPageMutex(page) + mutex.Lock() + defer mutex.Unlock() + + if _, ok := c.viewPageMap[page]; !ok { + c.viewPageMap[page] = make(map[string]struct{}) + } + c.viewPageMap[page][hash] = struct{}{} +} + +func batchSave(tx *sql.Tx, table string, pageMap map[string]map[string]struct{}) (err error) { + if _, err = tx.Exec(fmt.Sprintf("delete from %s;", table)); err != nil { + return fmt.Errorf("fail to truncate table %s: %w", table, err) + } + + userIdBytes := make(map[string][]byte) + + sqlStatement := fmt.Sprintf(` + INSERT OR IGNORE INTO %s (page_ref, user_id) + VALUES %s(?, ?); + `, table, strings.Repeat("(?, ?), ", 99)) + sqlStatementVars := make([]any, 0, 200) + + for pageRef, userSet := range pageMap { + for userId := range userSet { + if _, ok := userIdBytes[userId]; !ok { + userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId) + if err != nil { + slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error())) + continue + } + } + + sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId])) + if len(sqlStatementVars) < 200 { + continue + } + + if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { + slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error())) + } + + sqlStatementVars = make([]any, 0, 200) + } + } + + if len(sqlStatementVars) > 0 { + sqlStatement = fmt.Sprintf(` + INSERT OR IGNORE INTO %s (page_ref, user_id) + VALUES %s(?, ?); + `, table, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1)) + + if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { + slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error())) + } + } + + return nil +} diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go index d8adab6..4bf9a49 100644 --- a/internal/templatemanager/templatemanager.go +++ b/internal/templatemanager/templatemanager.go @@ -22,13 +22,22 @@ type TemplateManagerTemplates struct { Files []string } +var templateFuncMap = template.FuncMap{ + "contains": strings.Contains, + "iterate": func(count uint) []uint { + items := make([]uint, count) + for i := range count { + items[i] = i + } + return items + }, +} + func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) { templateMap := make(map[string]templateManagerRender) for _, tmplStruct := range templates { - tmpl := template.New("").Funcs(template.FuncMap{ - "contains": strings.Contains, - }) + tmpl := template.New("").Funcs(templateFuncMap) tmpl, err := tmpl.ParseFiles(tmplStruct.Files...) if err != nil { return nil, err @@ -75,9 +84,7 @@ func (tm *TemplateManager) Add(name string, files ...string) error { return fmt.Errorf("you can't add template without any files") } - tmpl := template.New("").Funcs(template.FuncMap{ - "contains": strings.Contains, - }) + tmpl := template.New("").Funcs(templateFuncMap) tmpl, err := tmpl.ParseFiles(files...) if err != nil { |