Diffstat (limited to 'internal/router')
| -rw-r--r-- | internal/router/api-v1-blog-search.go | 34 | ||||
| -rw-r--r-- | internal/router/api-v1-email-is-in-verification.go | 58 | ||||
| -rw-r--r-- | internal/router/api-v1-email-send-verification-code.go | 98 | ||||
| -rw-r--r-- | internal/router/api-v1-email-verify.go | 52 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-body.go | 243 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-bottom-embeds.go | 86 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-footer.go | 86 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-header.go | 98 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-top-embeds.go | 86 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page.go | 59 | ||||
| -rw-r--r-- | internal/router/api-v1-like.go | 122 | ||||
| -rw-r--r-- | internal/router/api-v1-subs.go | 67 | ||||
| -rw-r--r-- | internal/router/client-cache.go | 308 | ||||
| -rw-r--r-- | internal/router/lang-blog-title.go | 83 | ||||
| -rw-r--r-- | internal/router/lang-blog.go | 93 | ||||
| -rw-r--r-- | internal/router/lang-map.go | 15 | ||||
| -rw-r--r-- | internal/router/lang-user-unsubscribe.go | 71 | ||||
| -rw-r--r-- | internal/router/page-cache.go | 5 | ||||
| -rw-r--r-- | internal/router/root.go | 27 |
19 files changed, 1455 insertions, 236 deletions
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go index a809783..5b1d14d 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" @@ -9,36 +10,49 @@ import ( "strings" "time" + "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/internal/b2" "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" ) func init() { - tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html") + assert(0, tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html")) } -func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { +func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { sort := c.Query("sort") lang := c.Query("lang") tz := c.Query("tz") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } } + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + langIsAvailable: loc, err := time.LoadLocation(tz) if err != nil { loc = time.UTC 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() @@ -69,6 +83,8 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie "ShortDescription": page.Metadata.ShortDescription, "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-email-is-in-verification.go b/internal/router/api-v1-email-is-in-verification.go new file mode 100644 index 0000000..83c783c --- /dev/null +++ b/internal/router/api-v1-email-is-in-verification.go @@ -0,0 +1,58 @@ +package router + +import ( + "fmt" + "net/url" + "strings" + + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html")) +} + +func Api_V1_Email_IsInVerification(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + id := c.IP() + lang := "en" + + var referer, path string + var pathParts []string + var urlStruct *url.URL + var err error + + referer = c.Get("Referer", "") + if referer == "" { + goto skipFetchingLang + } + + urlStruct, err = url.ParseRequestURI(referer) + if err != nil { + goto skipFetchingLang + } + + path = urlStruct.EscapedPath() + pathParts = strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 0 { + goto skipFetchingLang + } + + lang = pathParts[0] + + skipFetchingLang: + isAllowed, whenAllowed, codeExpiry := Mailer.IsAllowedToRetryVerification(id) + if !isAllowed { + return api_v1_email_sendStatusHtml(c, "email-message", l, "Neutral", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{ + "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()), + "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()), + }) + } + return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, "", map[string]string{ + "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()), + }) + } +} diff --git a/internal/router/api-v1-email-send-verification-code.go b/internal/router/api-v1-email-send-verification-code.go new file mode 100644 index 0000000..9372eb3 --- /dev/null +++ b/internal/router/api-v1-email-send-verification-code.go @@ -0,0 +1,98 @@ +package router + +import ( + "fmt" + "html/template" + "log/slog" + "net/url" + "strings" + + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html")) +} + +func Api_V1_Email_SendVerificationCode(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) != 2 { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'") + } + + lang := pathParts[0] + id := c.IP() + + email := c.FormValue("email") + if email == "" { + return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailEmpty, map[string]string{}) + } + + isTaken, err := Mailer.MailIsTaken(email) + if err != nil { + slog.Error("failed to check if address is already taken", slog.String("error", err.Error())) + return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{}) + } + if isTaken { + return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailTaken, map[string]string{}) + } + + if isAllowed, whenAllowed, _ := Mailer.IsAllowedToRetryVerification(id); !isAllowed { + return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, strings.ReplaceAll(l[lang].UserProfile.DelayTilVerification, "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")), map[string]string{ + "striked-end-time": fmt.Sprint(whenAllowed.UnixMilli()), + }) + } + + if previousEmail, _, _ := Mailer.GetInfo(Mailer.GetHash(id)); previousEmail == email { + return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.EmailAlreadyValidated, map[string]string{}) + } + + if err = Mailer.SendVerificationCode(id, email, lang); err != nil { + slog.Error("failed to send a verification code", slog.String("error", err.Error())) + return api_v1_email_sendStatusHtml(c, "email-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationCodeSendingError, map[string]string{}) + } + + _, endTime, codeExpiry := Mailer.IsAllowedToRetryVerification(id) + return api_v1_email_sendStatusHtml(c, "email-message", l, "OK", fiber.StatusOK, lang, strings.ReplaceAll(l[lang].UserProfile.VerificationCodeSent, "{}", endTime.Format("2006-01-02 15:04:05 MST")), map[string]string{ + "striked-end-time": fmt.Sprint(endTime.UnixMilli()), + "code-expiry-time": fmt.Sprint(codeExpiry.UnixMilli()), + }) + } +} + +func api_v1_email_sendStatusHtml(c *fiber.Ctx, divId string, l map[string]*locale.LocaleConfig, status string, code int, lang string, message string, dataAttributes map[string]string) error { + sterileDataset := make(map[string]interface{}) + for k, v := range dataAttributes { + sterileDataset[k] = template.HTMLAttr(fmt.Sprintf("data-%s=\"%s\"", k, template.HTMLEscapeString(v))) + } + + content, err := tm.Render("personal-page-status", fiber.Map{ + "L": l[lang], + "Status": status, + "Message": message, + "StatusId": divId, + "DataAttributes": sterileDataset, + }) + if err != nil { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + slog.Error("failed to render the email status message", slog.String("error", err.Error()), slog.String("div_id", divId), slog.String("message", message)) + return c.Status(fiber.ErrInternalServerError.Code).SendString("Failed to render the email status message, please ask administrator for a more detailed cause") + } + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(code).Send(content) +} diff --git a/internal/router/api-v1-email-verify.go b/internal/router/api-v1-email-verify.go new file mode 100644 index 0000000..7fbfdec --- /dev/null +++ b/internal/router/api-v1-email-verify.go @@ -0,0 +1,52 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strings" + + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html")) +} + +func Api_V1_Email_Verify(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) != 2 { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/user'") + } + + lang := pathParts[0] + + verificationCode := c.FormValue("email_code") + if verificationCode == "" { + return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationEmpty, map[string]string{}) + } + + if err = Mailer.Verify(verificationCode, lang); err != nil { + slog.Error("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error())) + return api_v1_email_sendStatusHtml(c, "verification-message", l, "Failed", fiber.ErrUnprocessableEntity.Code, lang, l[lang].UserProfile.VerificationFailed, map[string]string{}) + } + return api_v1_email_sendStatusHtml(c, "verification-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.VerificationSuccess+"\n\n"+l[lang].UserProfile.RefreshPage, map[string]string{ + "hide-verification-panel": "true", + }) + } +} diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go new file mode 100644 index 0000000..b5567b7 --- /dev/null +++ b/internal/router/api-v1-general-page-body.go @@ -0,0 +1,243 @@ +package router + +import ( + "bytes" + "fmt" + "html/template" + "log/slog" + "math/rand" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/blogtrigger" + "github.com/SayaAndy/saya-today-web/internal/factgiver" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" + "github.com/SayaAndy/saya-today-web/internal/mailer" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" + "github.com/yuin/goldmark" +) + +var FactGiver *factgiver.FactGiver +var Mailer *mailer.Mailer +var BlogTrigger *blogtrigger.BlogTriggerScheduler + +func init() { + assert(0, tm.Add("general-page-body", "views/partials/general-page-body.html")) +} + +func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + trimmedPath := strings.Trim(path, "/") + + cacheKey := fmt.Sprintf("body.%s", trimmedPath) + if val, ok := PCache.Get(cacheKey); !strings.HasSuffix(trimmedPath, "user") && val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + + lang := "" + pathParts := strings.Split(trimmedPath, "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} + } + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + } + + langIsAvailable: + values := fiber.Map{ + "L": l[lang], + "Lang": lang, + "Path": strings.Trim(path, "/"), + "QueryString": string(c.Request().URI().QueryString()), + } + var additionalTemplates []string + + if len(pathParts) == 2 && pathParts[1] == "blog" { + querySort := c.Query("sort") + if querySort == "" { + querySort = "publicationDateDesc" + } + + encodedQuery := c.Request().URI().QueryString() + re, err := regexp.Compile(`tags\[\]=([\w]+)`) + if err != nil { + slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") + } + decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) + matches := re.FindAllStringSubmatch(decodedQuery, -1) + + queryTags := make([]string, 0, len(matches)) + for _, match := range matches { + queryTags = append(queryTags, string(match[1])) + } + + tagsArray, err := getTags(b2Client, lang) + if err != nil { + slog.Warn("failed to get the available tags", slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags") + } + + 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) == 2 && pathParts[1] == "user" { + values["Title"] = l[lang].UserProfile.Header + + email, _, err := Mailer.GetInfo(Mailer.GetHash(c.IP())) + if err != nil { + slog.Error("get info from mailer about a client", slog.String("error", err.Error())) + } + + tagsArray, err := getTags(b2Client, lang) + if err != nil { + slog.Warn("failed to get the available tags", slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags") + } + + subscriptionType, tags, err := Mailer.GetSubscriptions(c.IP()) + if err != nil { + slog.Warn("failed to get the user subscriptions", slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to get the user subscriptions") + } + + switch subscriptionType { + case mailer.None: + values["TagsPicked"] = "none" + case mailer.All: + values["TagsPicked"] = "all" + case mailer.Specific: + values["TagsPicked"] = "specific" + } + + values["TagsPickedList"] = tags + + values["Email"] = email + values["EmailCode"] = c.Query("email_code") + values["ExistingTags"] = tagsArray + + additionalTemplates = append(additionalTemplates, "views/pages/user-page.html") + } else if len(pathParts) == 3 && pathParts[1] == "blog" { + metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2]) + if err != nil { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", pathParts[2])) + } + + geolocationParts := strings.Split(metadata.Geolocation, " ") + var x, y, areaError string + if len(geolocationParts) >= 2 { + x = geolocationParts[0] + y = geolocationParts[1] + } + if len(geolocationParts) >= 3 { + areaError = geolocationParts[2] + } + + values["MapLocationX"] = x + values["MapLocationY"] = y + values["MapLocationAreaMeters"] = areaError + values["Title"] = metadata.Title + values["ParsedMarkdown"] = template.HTML(parsedMarkdown) + 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") + + 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") + } else if len(pathParts) == 0 { + values["Title"] = "Choose Your Language" + values["AvailableLanguages"] = langs + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") + } + + content, err := tm.Render("general-page-body", values, additionalTemplates...) + 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.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute) + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(content) + } +} + +func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) { + metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md") + if err != nil { + return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err) + } + + var buf bytes.Buffer + if err := md.Convert(markdown, &buf); err != nil { + return nil, "", fmt.Errorf("convert source context from md to html: %w", err) + } + + return metadata, buf.String(), nil +} + +type Tag struct { + Name string `json:"Name" yaml:"name"` + Count int `json:"Count" yaml:"count"` +} + +func getTags(b2Client *b2.B2Client, lang string) (tags []Tag, err error) { + pages, err := b2Client.Scan(lang + "/") + if err != nil { + slog.Warn("failed to scan pages via b2", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to scan pages via b2: %w", err) + } + + tagsMap := make(map[string]int) + for _, page := range pages { + for _, tag := range page.Metadata.Tags { + tagsMap[tag]++ + } + } + slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("lang", lang)) + + tagsArray := make([]Tag, 0, len(tagsMap)) + for tag, count := range tagsMap { + tagsArray = append(tagsArray, Tag{tag, count}) + } + slices.SortFunc(tagsArray, func(a Tag, b Tag) int { + return strings.Compare(a.Name, b.Name) + }) + + return tagsArray, nil +} diff --git a/internal/router/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go new file mode 100644 index 0000000..b408187 --- /dev/null +++ b/internal/router/api-v1-general-page-bottom-embeds.go @@ -0,0 +1,86 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("general-page-bottom-embeds", "views/partials/general-page-bottom-embeds.html")) +} + +func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + cacheKey := fmt.Sprintf("bottom-embeds.%s", path) + if val, ok := PCache.Get(cacheKey); val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + + lang := "" + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} + } + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + } + + langIsAvailable: + values := fiber.Map{ + "L": l[lang], + "Lang": lang, + "Path": strings.Trim(path, "/"), + "QueryString": string(c.Request().URI().QueryString()), + } + var additionalTemplates []string + + if len(pathParts) == 2 && pathParts[1] == "blog" { + additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html") + } else if len(pathParts) == 2 && pathParts[1] == "user" { + additionalTemplates = append(additionalTemplates, "views/pages/user-page.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") + } else if len(pathParts) == 0 { + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") + } + + content, err := tm.Render("general-page-bottom-embeds", values, additionalTemplates...) + 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.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute) + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(content) + } +} diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go new file mode 100644 index 0000000..99d899a --- /dev/null +++ b/internal/router/api-v1-general-page-footer.go @@ -0,0 +1,86 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("general-page-footer", "views/partials/general-page-footer.html")) +} + +func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + + cacheKey := fmt.Sprintf("footer.%s", path) + if val, ok := PCache.Get(cacheKey); val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + + lang := "" + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} + } + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + } + + langIsAvailable: + values := fiber.Map{ + "L": l[lang], + "Lang": lang, + "Path": strings.Trim(path, "/"), + "QueryString": string(c.Request().URI().QueryString()), + } + var additionalTemplates []string + + if len(pathParts) == 2 && pathParts[1] == "blog" { + additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html") + } else if len(pathParts) == 2 && pathParts[1] == "user" { + additionalTemplates = append(additionalTemplates, "views/pages/user-page.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") + } else if len(pathParts) == 0 { + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") + } + + content, err := tm.Render("general-page-footer", values, additionalTemplates...) + 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.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute) + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(content) + } +} diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go new file mode 100644 index 0000000..768417d --- /dev/null +++ b/internal/router/api-v1-general-page-header.go @@ -0,0 +1,98 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("general-page-header", "views/partials/general-page-header.html")) +} + +func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + + cacheKey := fmt.Sprintf("header.%s", path) + if val, ok := PCache.Get(cacheKey); val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + + lang := "" + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} + } + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + } + + langIsAvailable: + values := fiber.Map{ + "L": l[lang], + "Lang": lang, + "Path": strings.Trim(path, "/"), + "QueryString": string(c.Request().URI().QueryString()), + } + var additionalTemplates []string + + if len(pathParts) == 2 && pathParts[1] == "blog" { + values["Title"] = l[lang].BlogSearch.Header + additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html") + } else if len(pathParts) == 2 && pathParts[1] == "user" { + values["Title"] = l[lang].UserProfile.Header + additionalTemplates = append(additionalTemplates, "views/pages/user-page.html") + } else if len(pathParts) == 3 && pathParts[1] == "blog" { + metadata, _, err := b2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md") + if err != nil { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("could not read '%s' for content", path)) + } + values["Title"] = metadata.Title + 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") + } else if len(pathParts) == 0 { + values["Title"] = "Choose Your Language" + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") + } + + content, err := tm.Render("general-page-header", values, additionalTemplates...) + 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.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute) + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(content) + } +} diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go new file mode 100644 index 0000000..f3e480b --- /dev/null +++ b/internal/router/api-v1-general-page-top-embeds.go @@ -0,0 +1,86 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("general-page-top-embeds", "views/partials/general-page-top-embeds.html")) +} + +func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + + cacheKey := fmt.Sprintf("top-embeds.%s", path) + if val, ok := PCache.Get(cacheKey); val != nil && ok { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(val) + } + + lang := "" + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} + } + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + } + + langIsAvailable: + values := fiber.Map{ + "L": l[lang], + "Lang": lang, + "Path": strings.Trim(path, "/"), + "QueryString": string(c.Request().URI().QueryString()), + } + var additionalTemplates []string + + if len(pathParts) == 2 && pathParts[1] == "blog" { + additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html") + } else if len(pathParts) == 2 && pathParts[1] == "user" { + additionalTemplates = append(additionalTemplates, "views/pages/user-page.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") + } else if len(pathParts) == 0 { + additionalTemplates = append(additionalTemplates, "views/pages/language-pick.html") + } + + content, err := tm.Render("general-page-top-embeds", values, additionalTemplates...) + 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.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute) + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Type("html").Send(content) + } +} diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go new file mode 100644 index 0000000..c8b4362 --- /dev/null +++ b/internal/router/api-v1-general-page.go @@ -0,0 +1,59 @@ +package router + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("general-page", "views/layouts/general-page.html")) +} + +func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + path := c.Path() + + lang := "" + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 1 && pathParts[0] == "" { + pathParts = []string{} + } + if len(pathParts) > 0 { + lang = pathParts[0] + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'") + } + + langIsAvailable: + queryString := string(c.Request().URI().QueryString()) + cacheKey := fmt.Sprintf("general-page.%s?%s", lang, queryString) + 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": 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/api-v1-like.go b/internal/router/api-v1-like.go new file mode 100644 index 0000000..323c457 --- /dev/null +++ b/internal/router/api-v1-like.go @@ -0,0 +1,122 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html")) +} + +func Api_V1_Like_Put(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) != 3 { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'") + } + + lang, page := pathParts[0], pathParts[2] + + pageLink := lang + "/" + page + ".md" + if pages, _ := b2.Scan(pageLink); len(pages) == 0 { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink)) + } + + newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true")) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value") + } + + ip := c.IP() + if newLikeStatus { + CCache.LikeOn(ip, page) + } else { + CCache.LikeOff(ip, page) + } + + slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus))) + if c.Get("HX-Request", "false") == "true" { + content, err := tm.Render("blog-page-like-button", fiber.Map{ + "L": l[lang], + "Liked": newLikeStatus, + "LikedCount": CCache.GetLikeCount(page), + }) + 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") + } + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Send(content) + } + + return c.Status(fiber.StatusOK).SendString(fmt.Sprint(newLikeStatus)) + } +} + +func Api_V1_Like_Get(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) != 3 { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'") + } + + lang, page := pathParts[0], pathParts[2] + + pageLink := lang + "/" + page + ".md" + if pages, _ := b2.Scan(pageLink); len(pages) == 0 { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink)) + } + + ip := c.IP() + likeStatus := CCache.GetLikeStatus(ip, page) + + slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus)) + if c.Get("HX-Request", "false") == "true" { + content, err := tm.Render("blog-page-like-button", fiber.Map{ + "L": l[lang], + "Liked": likeStatus, + "LikedCount": CCache.GetLikeCount(page), + }) + 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") + } + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Send(content) + } + + return c.Status(fiber.StatusOK).SendString(fmt.Sprint(likeStatus)) + } +} diff --git a/internal/router/api-v1-subs.go b/internal/router/api-v1-subs.go new file mode 100644 index 0000000..72f16e0 --- /dev/null +++ b/internal/router/api-v1-subs.go @@ -0,0 +1,67 @@ +package router + +import ( + "net/url" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/mailer" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html")) +} + +func Api_V1_Subs_Put(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + id := c.IP() + lang := "en" + + var referer, path string + var pathParts []string + var urlStruct *url.URL + var err error + + referer = c.Get("Referer", "") + if referer == "" { + goto skipFetchingLang + } + + urlStruct, err = url.ParseRequestURI(referer) + if err != nil { + goto skipFetchingLang + } + + path = urlStruct.EscapedPath() + pathParts = strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 0 { + goto skipFetchingLang + } + + lang = pathParts[0] + + skipFetchingLang: + subscriptionType := c.FormValue("tags") + var subscriptionTypeEnum mailer.SubscriptionType + switch subscriptionType { + case "all": + subscriptionTypeEnum = mailer.All + case "none": + subscriptionTypeEnum = mailer.None + case "specific": + subscriptionTypeEnum = mailer.Specific + default: + return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.SubscribeInvalidType, map[string]string{}) + } + + specificTags := c.FormValue("tags_picked") + if err = Mailer.Subscribe(Mailer.GetHash(id), subscriptionTypeEnum, specificTags); err != nil { + return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.FailedToSubscribe, map[string]string{}) + } + + return api_v1_email_sendStatusHtml(c, "subs-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.SubscribedSuccessfully, map[string]string{}) + } +} diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index a4a0cc2..e7cda30 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -1,48 +1,320 @@ package router import ( + "database/sql" "encoding/base64" + "fmt" "log/slog" + "strings" "sync" "golang.org/x/crypto/argon2" ) type ClientCache struct { - hashMap map[string][]byte - mutexMap map[string]*sync.Mutex - salt []byte + hashMap map[string]string + hashMapMutex sync.RWMutex + + likePageMap map[string]map[string]struct{} + viewPageMap map[string]map[string]struct{} + pageMutexMap map[string]*sync.RWMutex + pageMutexMapMutex sync.Mutex + + salt []byte + db *sql.DB } var CCache *ClientCache -func NewClientCache(salt []byte) *ClientCache { - return &ClientCache{ - hashMap: make(map[string][]byte), - mutexMap: make(map[string]*sync.Mutex), - salt: salt, +func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { + tx, err := db.Begin() + if err != nil { + return nil, fmt.Errorf("fail to init transaction with db to fill cache: %w", err) } + slog.Debug("began db transaction", slog.String("method", "NewClientCache")) + + rows, err := tx.Query("select * from blog_likes;") + if err != nil { + tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) + return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err) + } + + likePageMap := make(map[string]map[string]struct{}) + viewPageMap := make(map[string]map[string]struct{}) + pageMutexMap := make(map[string]*sync.RWMutex) + + 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_likes to fill cache: %w", err) + } + 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() + slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) + 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 { + tx.Rollback() + slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) + return nil, fmt.Errorf("fail to commit transaction in db: %w", err) + } + slog.Debug("ended db transaction", slog.String("method", "NewClientCache")) + + return &ClientCache{ + hashMap: make(map[string]string), + likePageMap: likePageMap, + viewPageMap: viewPageMap, + pageMutexMap: pageMutexMap, + salt: salt, + db: db, + }, nil } -func (c *ClientCache) GetHash(id string) []byte { +func (c *ClientCache) Close() error { + tx, err := c.db.Begin() + if err != nil { + return fmt.Errorf("fail to init transaction with db to dump cache: %w", err) + } + slog.Debug("began db transaction in ClientCache.Close") + + if err = batchSave(tx, "blog_likes", c.likePageMap); err != nil { + tx.Rollback() + slog.Debug("ended db transaction in ClientCache.Close") + return fmt.Errorf("fail to save blog_likes: %s", err) + } + + if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil { + tx.Rollback() + slog.Debug("ended db transaction in ClientCache.Close") + return fmt.Errorf("fail to save blog_views: %s", err) + } + + if err = tx.Commit(); err != nil { + tx.Rollback() + slog.Debug("ended db transaction in ClientCache.Close") + return fmt.Errorf("fail to commit all the changes related to cache: %s", err) + } + + slog.Debug("ended db transaction in ClientCache.Close") + return nil +} +func (c *ClientCache) GetHash(id string) string { + c.hashMapMutex.RLock() if val, ok := c.hashMap[id]; ok { - slog.Debug("gave an old hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) + c.hashMapMutex.RUnlock() + slog.Debug("gave an old hash", slog.String("hash", val)) return val } + c.hashMapMutex.RUnlock() - if _, ok := c.mutexMap[id]; !ok { - c.mutexMap[id] = &sync.Mutex{} - } - c.mutexMap[id].Lock() - defer c.mutexMap[id].Unlock() + c.hashMapMutex.Lock() + defer c.hashMapMutex.Unlock() if val, ok := c.hashMap[id]; ok { - slog.Debug("gave a newly generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) + slog.Debug("gave a newly generated hash", slog.String("hash", val)) return val } - c.hashMap[id] = argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32) - slog.Debug("generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(c.hashMap[id]))) + c.hashMap[id] = base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32)) + slog.Debug("generated hash", slog.String("hash", c.hashMap[id])) return c.hashMap[id] } + +func (c *ClientCache) getPageMutex(page string) *sync.RWMutex { + c.pageMutexMapMutex.Lock() + defer c.pageMutexMapMutex.Unlock() + + if mutex, ok := c.pageMutexMap[page]; ok { + return mutex + } + + c.pageMutexMap[page] = &sync.RWMutex{} + return c.pageMutexMap[page] +} + +func (c *ClientCache) GetLikeStatus(id string, page string) bool { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + + if _, ok := c.likePageMap[page]; !ok { + return false + } + _, ok := c.likePageMap[page][c.GetHash(id)] + return ok +} + +func (c *ClientCache) GetLikeCount(page string) int { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + + if userSet, ok := c.likePageMap[page]; ok { + return len(userSet) + } + return 0 +} + +func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { + page = strings.Clone(page) + hash := c.GetHash(id) + + mutex := c.getPageMutex(page) + mutex.Lock() + defer mutex.Unlock() + + if userSet, ok := c.likePageMap[page]; ok { + _, alreadyLiked = userSet[hash] + c.likePageMap[page][hash] = struct{}{} + return + } + + c.likePageMap[page] = make(map[string]struct{}) + c.likePageMap[page][hash] = struct{}{} + return +} + +func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) { + page = strings.Clone(page) + hash := c.GetHash(id) + + mutex := c.getPageMutex(page) + mutex.Lock() + defer mutex.Unlock() + + if _, ok := c.likePageMap[page]; !ok { + return true + } + if _, ok := c.likePageMap[page][hash]; !ok { + return true + } + + 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/router/lang-blog-title.go b/internal/router/lang-blog-title.go deleted file mode 100644 index 5b80ec8..0000000 --- a/internal/router/lang-blog-title.go +++ /dev/null @@ -1,83 +0,0 @@ -package router - -import ( - "bytes" - "fmt" - "html/template" - "log/slog" - "slices" - "strconv" - "strings" - - "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/SayaAndy/saya-today-web/internal/frontmatter" - "github.com/SayaAndy/saya-today-web/locale" - "github.com/gofiber/fiber/v2" - "github.com/yuin/goldmark" -) - -func init() { - tm.Add("blog-page", "views/layouts/general-page.html", "views/pages/blog-page.html") -} - -func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - ip := c.IP() - slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path())) - go CCache.GetHash(ip) - - lang := c.Params("lang") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) - } - - metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+c.Params("title")) - if err != nil { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title"))) - } - - geolocationParts := strings.Split(metadata.Geolocation, " ") - var x, y, areaError string - if len(geolocationParts) >= 2 { - x = geolocationParts[0] - y = geolocationParts[1] - } - if len(geolocationParts) >= 3 { - areaError = geolocationParts[2] - } - - content, err := tm.Render("blog-page", fiber.Map{ - "Title": metadata.Title, - "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"), - "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()), - "ParsedMarkdown": template.HTML(parsedMarkdown), - "MapLocationX": x, - "MapLocationY": y, - "MapLocationAreaMeters": areaError, - "Lang": lang, - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), slog.String("error", err.Error())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") - } - - return c.Type("html").Send(content) - } -} - -func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) { - metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md") - if err != nil { - return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err) - } - - var buf bytes.Buffer - if err := md.Convert(markdown, &buf); err != nil { - return nil, "", fmt.Errorf("convert source context from md to html: %w", err) - } - - return metadata, buf.String(), nil -} diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go deleted file mode 100644 index e5220c6..0000000 --- a/internal/router/lang-blog.go +++ /dev/null @@ -1,93 +0,0 @@ -package router - -import ( - "fmt" - "log/slog" - "net/url" - "regexp" - "slices" - "strings" - - "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/SayaAndy/saya-today-web/locale" - "github.com/gofiber/fiber/v2" -) - -func init() { - tm.Add("blog-catalogue", "views/layouts/general-page.html", "views/pages/blog-catalogue.html") -} - -func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - lang := c.Params("lang") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) - } - - querySort := c.Query("sort") - if querySort == "" { - querySort = "publicationDateDesc" - } - - encodedQuery := c.Request().URI().QueryString() - re, err := regexp.Compile(`tags\[\]=([\w]+)`) - if err != nil { - slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") - } - decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) - matches := re.FindAllStringSubmatch(decodedQuery, -1) - - queryTags := make([]string, 0, len(matches)) - for _, match := range matches { - queryTags = append(queryTags, string(match[1])) - } - - pages, err := b2Client.Scan(lang + "/") - status := fiber.StatusOK - if err != nil { - status = fiber.StatusPartialContent - pages = []*b2.BlogPage{} - } - - tagsMap := make(map[string]int) - for _, page := range pages { - for _, tag := range page.Metadata.Tags { - tagsMap[tag]++ - } - } - slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path())) - - type Tag struct { - Name string `json:"Name" yaml:"name"` - Count int `json:"Count" yaml:"count"` - } - - tagsArray := make([]Tag, 0, len(tagsMap)) - for tag, count := range tagsMap { - tagsArray = append(tagsArray, Tag{tag, count}) - } - slices.SortFunc(tagsArray, func(a Tag, b Tag) int { - return strings.Compare(a.Name, b.Name) - }) - - content, err := tm.Render("blog-catalogue", fiber.Map{ - "QuerySort": querySort, - "QueryTags": strings.Join(queryTags, ","), - "Tags": tagsArray, - "Lang": lang, - "L": l[lang], - "PublishedYear": "2025", - "Title": l[lang].BlogSearch.Header, - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") - } - - return c.Type("html").Status(status).Send(content) - } -} diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go index e94d756..619b05b 100644 --- a/internal/router/lang-map.go +++ b/internal/router/lang-map.go @@ -3,27 +3,30 @@ package router import ( "fmt" "log/slog" - "slices" "strconv" "strings" + "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/internal/b2" "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" ) func init() { - tm.Add("global-map", "views/pages/global-map.html") + assert(0, tm.Add("global-map", "views/pages/global-map.html")) } -func Lang_Map(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { +func Lang_Map(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig, b2Client *b2.B2Client) func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error { lang := c.Params("lang") - if !slices.Contains(langs, lang) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } } + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + langIsAvailable: pages, err := b2Client.Scan(lang + "/") status := fiber.StatusOK if err != nil { diff --git a/internal/router/lang-user-unsubscribe.go b/internal/router/lang-user-unsubscribe.go new file mode 100644 index 0000000..c39f5e7 --- /dev/null +++ b/internal/router/lang-user-unsubscribe.go @@ -0,0 +1,71 @@ +package router + +import ( + "fmt" + "html/template" + "log/slog" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("unsubscribe-page", "views/pages/unsubscribe-page.html")) +} + +func Lang_User_Unsubscribe(l map[string]*locale.LocaleConfig, langs []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + lang := c.Params("lang") + for _, availableLang := range langs { + if availableLang.Name == lang { + goto langIsAvailable + } + } + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + + langIsAvailable: + var statusEmoji, statusText, statusColor string + var status int + + unsubscribeCode := c.FormValue("code") + if unsubscribeCode == "" { + statusColor = "0, 0, 255" + statusEmoji = "(╭ರ_•́)" + statusText = l[lang].UnsubscribePage.UnsetCode + status = fiber.ErrBadRequest.Code + } else if clientError, serverError := Mailer.Unsubscribe(unsubscribeCode); clientError != nil { + slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error())) + statusColor = "255, 0, 0" + statusEmoji = "(͠≖~≖ ͡ )" + statusText = l[lang].UnsubscribePage.InvalidCode + status = fiber.ErrBadRequest.Code + } else if serverError != nil { + slog.Error("got a server error when unsubscribing", slog.String("error", serverError.Error())) + statusColor = "255, 128, 0" + statusEmoji = "( ˶°ㅁ°) !!" + statusText = l[lang].UnsubscribePage.OnServerError + status = fiber.ErrInternalServerError.Code + } else { + statusColor = "0, 255, 0" + statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡" + statusText = l[lang].UnsubscribePage.Success + status = fiber.StatusOK + } + + content, err := tm.Render("unsubscribe-page", fiber.Map{ + "Lang": lang, + "L": l[lang], + "StatusEmoji": statusEmoji, + "StatusText": statusText, + "StatusColor": template.HTML(statusColor), + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/user/unsubscribe"), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Status(status).Send(content) + } +} diff --git a/internal/router/page-cache.go b/internal/router/page-cache.go new file mode 100644 index 0000000..01e4792 --- /dev/null +++ b/internal/router/page-cache.go @@ -0,0 +1,5 @@ +package router + +import "github.com/dgraph-io/ristretto/v2" + +var PCache *ristretto.Cache[string, []byte] diff --git a/internal/router/root.go b/internal/router/root.go deleted file mode 100644 index f4a0692..0000000 --- a/internal/router/root.go +++ /dev/null @@ -1,27 +0,0 @@ -package router - -import ( - "log/slog" - - "github.com/SayaAndy/saya-today-web/config" - "github.com/gofiber/fiber/v2" -) - -func init() { - tm.Add("index", "views/index.html") -} - -func Root(localeCfg []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - content, err := tm.Render("index", fiber.Map{ - "AvailableLanguages": localeCfg, - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"), slog.String("error", err.Error())) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") - } - - return c.Type("html").Send(content) - } -} |