Diffstat (limited to 'internal')
31 files changed, 0 insertions, 3171 deletions
diff --git a/internal/b2/client.go b/internal/b2/client.go deleted file mode 100644 index d712736..0000000 --- a/internal/b2/client.go +++ /dev/null @@ -1,131 +0,0 @@ -package b2 - -import ( - "context" - "fmt" - "slices" - "strings" - "time" - - "github.com/Backblaze/blazer/b2" - "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/frontmatter" -) - -type B2Client struct { - prefix string - bucket *b2.Bucket - b2cl *b2.Client -} - -func NewB2Client(cfg *config.B2Config) (*B2Client, error) { - b2cl, err := b2.NewClient(context.Background(), cfg.KeyID, cfg.ApplicationKey) - if err != nil { - return nil, err - } - - bucket, err := b2cl.Bucket(context.Background(), cfg.BucketName) - if err != nil { - return nil, err - } - - return &B2Client{b2cl: b2cl, bucket: bucket, prefix: cfg.Prefix}, nil -} - -type BlogPage struct { - Link string - FileName string - Lang string - Metadata *frontmatter.Metadata -} - -func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { - filePaths := []*BlogPage{} - - iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix+prefix)) - - for iter.Next() { - obj := iter.Object() - if obj == nil { - return nil, fmt.Errorf("failed to reference object in B2 bucket") - } - - attrs, err := obj.Attrs(context.Background()) - if err != nil { - return nil, fmt.Errorf("get attributes for object: %w", err) - } - - if attrs.Status != b2.Uploaded { - continue - } - - if !strings.Contains(attrs.ContentType, "text/markdown") { - continue - } - - if _, ok := attrs.Info["title"]; !ok { - continue - } - - publishedTime, err := time.Parse(time.RFC3339, attrs.Info["published-time"]) - if err != nil { - return nil, fmt.Errorf("failed to parse published time metadata field: %w", err) - } - - linkParts := strings.Split(obj.Name(), "/") - nameParts := strings.Split(linkParts[len(linkParts)-1], ".") - fileName := strings.Join(nameParts[:len(linkParts)-1], ".") - - tags := strings.Split(attrs.Info["tags"], ",") - slices.Sort(tags) - - filePaths = append(filePaths, &BlogPage{ - Link: obj.Name(), - FileName: fileName, - Metadata: &frontmatter.Metadata{ - Title: attrs.Info["title"], - ShortDescription: attrs.Info["short-description"], - ActionDate: attrs.Info["action-date"], - PublishedTime: publishedTime, - Thumbnail: attrs.Info["thumbnail"], - Tags: tags, - Geolocation: attrs.Info["geolocation"], - }, - }) - } - - if err := iter.Err(); err != nil { - return nil, fmt.Errorf("iterate over B2 objects: %w", err) - } - - return filePaths, nil -} - -func (c *B2Client) ReadAll(path string) ([]byte, error) { - obj := c.bucket.Object(c.prefix + path) - if obj == nil { - return nil, fmt.Errorf("failed to reference object in B2 bucket") - } - attrs, err := obj.Attrs(context.Background()) - if err != nil { - return nil, fmt.Errorf("error getting attributes of an object: %w", err) - } - - content := make([]byte, attrs.Size) - reader := obj.NewReader(context.Background()) - - if _, err = reader.Read(content); err != nil { - return nil, fmt.Errorf("failed to read file content: %w", err) - } - - return content, nil -} - -func (c *B2Client) ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) { - contentBytes, err := c.ReadAll(path) - if err != nil { - return nil, nil, fmt.Errorf("failed to read file for frontmatter parsing: %w", err) - } - - return frontmatter.ParseFrontmatter(contentBytes) -} diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go deleted file mode 100644 index c5b3097..0000000 --- a/internal/blogtrigger/blogtrigger.go +++ /dev/null @@ -1,72 +0,0 @@ -package blogtrigger - -import ( - "fmt" - "log/slog" - - "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/go-co-op/gocron/v2" -) - -type BlogTriggerScheduler struct { - s gocron.Scheduler - knownBlogPages map[string]map[string]*b2.BlogPage - b2Client *b2.B2Client - onTrigger func([]*b2.BlogPage) error -} - -func NewBlogTriggerScheduler(b2Client *b2.B2Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*b2.BlogPage) error) (*BlogTriggerScheduler, error) { - s, err := gocron.NewScheduler() - if err != nil { - return nil, fmt.Errorf("failed to create new scheduler: %w", err) - } - - knownBlogPages := make(map[string]map[string]*b2.BlogPage, len(availableLanguages)) - for _, lang := range availableLanguages { - knownBlogPages[lang.Name] = make(map[string]*b2.BlogPage) - } - - bts := &BlogTriggerScheduler{s, knownBlogPages, b2Client, onTrigger} - defer bts.s.Start() - - bts.s.NewJob(gocron.CronJob(cron, false), gocron.NewTask(func(bts *BlogTriggerScheduler) { - posts, err := bts.scan() - if err != nil { - slog.Error("failed to execute scanning new blog pages cron job", slog.String("error", err.Error())) - return - } - if err = onTrigger(posts); err != nil { - slog.Error("error happened on callback function after scanning new blog pages", slog.String("error", err.Error())) - return - } - }, bts)) - - if _, err = bts.scan(); err != nil { - return nil, fmt.Errorf("failed to scan existing blog pages in b2: %w", err) - } - - return bts, nil -} - -func (bts *BlogTriggerScheduler) Close() error { - return bts.s.Shutdown() -} - -func (bts *BlogTriggerScheduler) scan() (newPages []*b2.BlogPage, err error) { - newPages = make([]*b2.BlogPage, 0) - for lang := range bts.knownBlogPages { - posts, err := bts.b2Client.Scan(lang + "/") - if err != nil { - return nil, fmt.Errorf("failed to scan blog pages in b2 on '%s': %w", lang, err) - } - for _, post := range posts { - if _, ok := bts.knownBlogPages[lang][post.FileName]; !ok { - post.Lang = lang - newPages = append(newPages, post) - bts.knownBlogPages[lang][post.FileName] = post - } - } - } - return newPages, nil -} diff --git a/internal/factgiver/factgiver.go b/internal/factgiver/factgiver.go deleted file mode 100644 index 5b418ee..0000000 --- a/internal/factgiver/factgiver.go +++ /dev/null @@ -1,67 +0,0 @@ -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 []config.AvailableLanguageConfig - factsFileName string - nlRe *regexp.Regexp - randGen *rand.Rand -} - -func NewFactGiver(cfg *config.FactGiverConfig, langs []config.AvailableLanguageConfig) (*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.Name, 1) - factsContentBytes, err := g.b2Client.ReadAll(localFacts) - if err != nil { - return fmt.Errorf("fail to read '%s' facts file: %s", lang.Name, err.Error()) - } - factsContent := string(factsContentBytes) - g.cache[lang.Name] = g.nlRe.Split(factsContent, -1) - if g.cache[lang.Name][len(g.cache[lang.Name])-1] == "" { - g.cache[lang.Name] = g.cache[lang.Name][:len(g.cache[lang.Name])-1] - } - } - return nil -} diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go deleted file mode 100644 index 83312dc..0000000 --- a/internal/frontmatter/parser.go +++ /dev/null @@ -1,40 +0,0 @@ -package frontmatter - -import ( - "fmt" - "regexp" - "time" - - "gopkg.in/yaml.v3" -) - -type Metadata struct { - Title string `yaml:"title"` - ShortDescription string `yaml:"shortDescription"` - ActionDate string `yaml:"actionDate"` - PublishedTime time.Time `yaml:"publishedTime"` - 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) { - frontmatterRegex := regexp.MustCompile(`^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$`) - matches := frontmatterRegex.FindSubmatch(content) - - if len(matches) != 3 { - return nil, content, nil - } - - yamlContent := matches[1] - markdownContent := matches[2] - - metadata = &Metadata{} - if err := yaml.Unmarshal([]byte(yamlContent), &metadata); err != nil { - return nil, nil, fmt.Errorf("failed to parse YAML frontmatter: %w", err) - } - - return metadata, markdownContent, nil -} diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go deleted file mode 100644 index e53e67b..0000000 --- a/internal/glightbox/block.go +++ /dev/null @@ -1,32 +0,0 @@ -package glightbox - -import ( - "time" - - "github.com/yuin/goldmark/ast" -) - -// GLightboxBlock represents a light gallery block in the AST -type GLightboxBlock struct { - ast.BaseBlock - Images []GLightboxImage - Location *time.Location -} - -type GLightboxImage struct { - URL string - Tags []string - Caption []byte -} - -var KindGLightboxBlock = ast.NewNodeKind("GLightboxBlock") - -// Dump implements ast.Node.Dump -func (n *GLightboxBlock) Dump(source []byte, level int) { - ast.DumpHelper(n, source, level, nil, nil) -} - -// Kind implements ast.Node.Kind -func (n *GLightboxBlock) Kind() ast.NodeKind { - return KindGLightboxBlock -} diff --git a/internal/glightbox/extension.go b/internal/glightbox/extension.go deleted file mode 100644 index 7fd17b1..0000000 --- a/internal/glightbox/extension.go +++ /dev/null @@ -1,28 +0,0 @@ -package glightbox - -import ( - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/util" -) - -// Extension that combines parser and renderer -type GLightboxExtension struct{} - -func NewGLightboxExtension() goldmark.Extender { - return &GLightboxExtension{} -} - -func (e *GLightboxExtension) Extend(m goldmark.Markdown) { - m.Parser().AddOptions( - parser.WithBlockParsers( - util.Prioritized(NewGLightboxParser(), 500), - ), - ) - m.Renderer().AddOptions( - renderer.WithNodeRenderers( - util.Prioritized(NewGLightboxHTMLRenderer(), 500), - ), - ) -} diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go deleted file mode 100644 index 7022793..0000000 --- a/internal/glightbox/html_renderer.go +++ /dev/null @@ -1,186 +0,0 @@ -package glightbox - -import ( - "bytes" - "fmt" - "math/rand" - "regexp" - "strings" - "time" - - "github.com/SayaAndy/saya-today-web/internal/tailwind" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/renderer/html" - "github.com/yuin/goldmark/util" -) - -type GLightboxHTMLRenderer struct { - html.Config - md goldmark.Markdown - anchorMatchRe *regexp.Regexp -} - -func NewGLightboxHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { - r := &GLightboxHTMLRenderer{ - Config: html.NewConfig(), - md: goldmark.New( - goldmark.WithParserOptions( - parser.WithAutoHeadingID(), - parser.WithAttribute(), - ), - goldmark.WithRenderer( - renderer.NewRenderer( - renderer.WithNodeRenderers( - util.Prioritized(tailwind.NewCustomLinkRenderer( - html.WithUnsafe(), html.WithHardWraps(), html.WithXHTML(), - ), 50), - util.Prioritized(html.NewRenderer( - html.WithUnsafe(), html.WithHardWraps(), html.WithXHTML(), - ), 100), - ), - ), - ), - ), - } - for _, opt := range opts { - opt.SetHTMLOption(&r.Config) - } - r.anchorMatchRe = regexp.MustCompile(`(?s)<\s*a(\s+[^<]*)(href\s*=\s*["'].*?["'])\s*([^<]*)>(.*?)<\s*\/\s*a\s*>`) - return r -} - -func (r *GLightboxHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(KindGLightboxBlock, r.renderGLightbox) -} - -func (r *GLightboxHTMLRenderer) renderGLightbox(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { - if entering { - gallery := n.(*GLightboxBlock) - - if len(gallery.Images) == 0 { - return ast.WalkContinue, nil - } - - galleryID := generateDivId(8) - - var elements []string - for i, img := range gallery.Images { - imageUrlSegments := strings.Split(img.URL, ".") - imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".") - imageUrlParts := strings.Split(imageUrlWithoutExt, "/") - imageNameParts := strings.Split(imageUrlParts[len(imageUrlParts)-1], "-") - - var captionBuf bytes.Buffer - captionHTML := img.Caption - if err := r.md.Convert(img.Caption, &captionBuf); err == nil { - captionHTML = captionBuf.Bytes() - captionHTML = bytes.TrimPrefix(captionHTML, []byte("<p>")) - captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>\n")) - captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>")) - } - - dayDate, _ := time.Parse("20060102 150405", imageNameParts[len(imageNameParts)-2]+" "+imageNameParts[len(imageNameParts)-1]) - dayDate = dayDate.In(gallery.Location) - - glightboxDescId := "" - if len(captionHTML) != 0 { - glightboxDescId = fmt.Sprintf("glightbox-desc-%s-%d", galleryID, i) - } - - dataDescriptionAttribute := "" - if glightboxDescId != "" { - dataDescriptionAttribute = fmt.Sprintf("data-description=\".%s\"", glightboxDescId) - } - - tagClassList := make([]string, 0, len(img.Tags)) - for _, tag := range img.Tags { - switch tag { - case "2x": - tagClassList = append(tagClassList, "grid-item-2x") - } - } - - if len(img.Caption) > 0 { - tagClassList = append(tagClassList, "grid-tooltip") - } - - anchorlessCaptionHTML := r.anchorMatchRe.ReplaceAll(captionHTML, []byte("<span class=\"linklike\" $1 $3>$4</span>")) - - elements = append(elements, fmt.Sprintf(` - <a href="https://f003.backblazeb2.com/file/sayana-photos/full/%s" class="glightbox grid-item %s grid-item-%s p-1" - data-gallery="gallery" data-title="%s" %s> - <picture> - <source media="(width < 800px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp" /> - <source media="(width < 2400px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp" /> - <source media="(width < 3200px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp" /> - <source media="(width < 4000px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp" /> - <source media="(width >= 4000px)" srcset="https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp" /> - <img src="https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp" /> - </picture> - <span class="grid-tooltip-text"><p>%s</p></span> - <span class="grid-item-index">%d</span> - </a>`, img.URL, strings.Join(tagClassList, " "), galleryID, dayDate.Format("2006-01-02 15:04:05 -07:00"), dataDescriptionAttribute, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, anchorlessCaptionHTML, i+1)) - - if glightboxDescId != "" { - elements = append(elements, fmt.Sprintf(` - <div class="glightbox-desc rounded-4 %s"> - <p>%s</p> - </div>`, glightboxDescId, captionHTML)) - } - } - - w.WriteString(fmt.Sprintf(` -<div class="justify-content-center display-block"> - <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto"> - <div class="grid masonry-grid-%s mx-auto"> - <div class="grid-sizer grid-sizer-%s"></div> - %s - </div> - <hr class="border-t-3 border-dotted border-main-hard mt-1 mb-2 w-[80%%] ml-auto mr-auto"> -</div>`, galleryID, galleryID, strings.Join(elements, "\n"))) - - w.WriteString(fmt.Sprintf(` -<script> - var msnry_%s = new Masonry('.masonry-grid-%s', { - itemSelector: '.grid-item-%s', - columnWidth: '.grid-sizer-%s', - percentPosition: false, - fitWidth: true - }); - - var imgLoad_%s_timer; - var imgLoad_%s = imagesLoaded('.masonry-grid-%s'); - - function initMasonryLayout_%s(tm) { - clearTimeout(imgLoad_%s_timer); - imgLoad_%s_timer = setTimeout(() => msnry_%s.layout(), 100); - } - - imgLoad_%s.on('progress', initMasonryLayout_%s); - - window.addEventListener('resize', initMasonryLayout_%s); - - document.addEventListener('popout', (e) => { - window.removeEventListener('resize', initMasonryLayout_%s); - imgLoad_%s.off('progress', initMasonryLayout_%s); - initMasonryLayout_%s = null; - imgLoad_%s = null; - }, {once: true}); -</script>`, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID, galleryID)) - } - - return ast.WalkContinue, nil -} - -func generateDivId(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyz" - seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] - } - return string(b) -} diff --git a/internal/glightbox/parser.go b/internal/glightbox/parser.go deleted file mode 100644 index 6780090..0000000 --- a/internal/glightbox/parser.go +++ /dev/null @@ -1,100 +0,0 @@ -package glightbox - -import ( - "bytes" - "log/slog" - "regexp" - "strings" - "time" - - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/text" -) - -type GLightboxParser struct{} - -func NewGLightboxParser() parser.BlockParser { - return &GLightboxParser{} -} - -func (p *GLightboxParser) Trigger() []byte { - return []byte{'{'} -} - -func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { - line, _ := reader.PeekLine() - - if !bytes.HasPrefix(line, []byte("{Gallery:")) { - return nil, parser.NoChildren - } - - r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)\}$`) - - trimmed := bytes.TrimSpace(line) - parts := r.FindSubmatch(trimmed) - if len(parts) < 2 { - slog.Warn("invalid gallery header format", slog.String("line", string(trimmed)), slog.Int("submatch_count", len(parts))) - return nil, parser.NoChildren - } - - loc, err := time.LoadLocation(string(parts[1])) - if err != nil { - slog.Warn("invalid location specified for a gallery", slog.String("error", err.Error()), slog.String("line", string(trimmed)), slog.String("location", string(parts[1]))) - return nil, parser.NoChildren - } - - return &GLightboxBlock{Location: loc}, parser.NoChildren -} - -func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { - line, segment := reader.PeekLine() - if len(line) == 0 || segment.Len() == 0 { - return parser.Continue | parser.NoChildren - } - - trimmed := bytes.TrimSpace(line) - if bytes.Equal(trimmed, []byte("{Gallery}")) || bytes.Equal(trimmed, []byte("{/Gallery}")) { - reader.AdvanceLine() - return parser.Close - } - - gallery := node.(*GLightboxBlock) - - parts := bytes.SplitN(trimmed, []byte{'|'}, 3) - url := bytes.TrimSpace(parts[0]) - - caption := make([]byte, 0) - tagsRaw := "" - if len(parts) == 2 { - caption = bytes.TrimSpace(parts[1]) - } - if len(parts) >= 3 { - tagsRaw = string(parts[1]) - caption = bytes.TrimSpace(parts[2]) - } - - tags := strings.Split(tagsRaw, ",") - for i := range tags { - tags[i] = strings.TrimSpace(tags[i]) - } - - gallery.Images = append(gallery.Images, GLightboxImage{ - URL: string(url), - Tags: tags, - Caption: caption, - }) - - return parser.Continue | parser.NoChildren -} - -func (p *GLightboxParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { -} - -func (p *GLightboxParser) CanInterruptParagraph() bool { - return true -} - -func (p *GLightboxParser) CanAcceptIndentedLine() bool { - return false -} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go deleted file mode 100644 index d6dbd17..0000000 --- a/internal/mailer/mailer.go +++ /dev/null @@ -1,544 +0,0 @@ -package mailer - -import ( - "crypto/rand" - "database/sql" - "encoding/base64" - "encoding/binary" - "fmt" - "html/template" - "log/slog" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/SayaAndy/saya-today-web/internal/templatemanager" - "github.com/SayaAndy/saya-today-web/locale" - "github.com/dgraph-io/ristretto/v2" - "github.com/gofiber/fiber/v2" - "github.com/wneessen/go-mail" - "golang.org/x/crypto/argon2" -) - -type Mailer struct { - verificationCodes *ristretto.Cache[uint64, string] - unsubscribeCodes *ristretto.Cache[uint64, []byte] - db *sql.DB - tm *templatemanager.TemplateManager - mailClient *mail.Client - clientHost string - mailAddress string - publicName string - salt []byte - - lostMailMap map[string]struct { - Dur time.Duration - End time.Time - CodeExpiry time.Time - } - lostMailMapMutex sync.RWMutex - - hashMap map[string][]byte - hashMapMutex sync.RWMutex - - l map[string]*locale.LocaleConfig -} - -type SubscriptionType int - -const ( - All SubscriptionType = iota - None - Specific -) - -func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string, mailAddress string, username string, password string, salt []byte, localization map[string]*locale.LocaleConfig) (*Mailer, error) { - verificationCodes, err := ristretto.NewCache(&ristretto.Config[uint64, string]{ - NumCounters: 10000, - MaxCost: 1 << 20, // 1 MB - BufferItems: 64, - TtlTickerDurationInSec: 3600, // 1 hour - }) - if err != nil { - return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err) - } - - unsubscribeCodes, err := ristretto.NewCache(&ristretto.Config[uint64, []byte]{ - NumCounters: 10000, - MaxCost: 1 << 20, // 1 MB - BufferItems: 64, - TtlTickerDurationInSec: 86400, // 1 day - }) - if err != nil { - return nil, fmt.Errorf("fail to initialize cache for verification codes: %w", err) - } - - tm, err := templatemanager.NewTemplateManager(templatemanager.TemplateManagerTemplates{ - Name: "new-post", - Files: []string{"views/layouts/general-mail.html", "views/messages/new-post.html"}, - }, templatemanager.TemplateManagerTemplates{ - Name: "verify-email", - Files: []string{"views/layouts/general-mail.html", "views/messages/verify-email.html"}, - }) - if err != nil { - return nil, fmt.Errorf("fail to initialize template manager for message templating: %w", err) - } - - mailClient, err := mail.NewClient(mailHost, - mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(mail.TLSMandatory), - mail.WithUsername(username), mail.WithPassword(password), - ) - if err != nil { - return nil, fmt.Errorf("fail to initialize mail client: %w", err) - } - - return &Mailer{ - verificationCodes: verificationCodes, - unsubscribeCodes: unsubscribeCodes, - db: db, - clientHost: clientHost, - tm: tm, - mailClient: mailClient, - mailAddress: mailAddress, - publicName: publicName, - salt: salt, - hashMap: make(map[string][]byte), - lostMailMap: make(map[string]struct { - Dur time.Duration - End time.Time - CodeExpiry time.Time - }, 0), - l: localization}, nil -} - -func (m *Mailer) GetHash(id string) []byte { - m.hashMapMutex.RLock() - if val, ok := m.hashMap[id]; ok { - m.hashMapMutex.RUnlock() - slog.Debug("gave an old hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) - return val - } - m.hashMapMutex.RUnlock() - - m.hashMapMutex.Lock() - defer m.hashMapMutex.Unlock() - - if val, ok := m.hashMap[id]; ok { - slog.Debug("gave a newly generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) - return val - } - - m.hashMap[id] = argon2.IDKey([]byte(id), m.salt, 1, 64*1024, 4, 32) - slog.Debug("generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(m.hashMap[id]))) - return m.hashMap[id] -} - -func (m *Mailer) IsAllowedToRetryVerification(userId string) (retryAllowed bool, whenAllowed time.Time, codeExpiry time.Time) { - m.lostMailMapMutex.RLock() - defer m.lostMailMapMutex.RUnlock() - previous, ok := m.lostMailMap[userId] - if ok && previous.End.After(time.Now()) { - return false, previous.End, previous.CodeExpiry - } - return true, time.Time{}, previous.CodeExpiry -} - -func (m *Mailer) MailIsTaken(email string) (bool, error) { - tx, err := m.db.Begin() - if err != nil { - return false, fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "MailIsTaken")) - defer func(tx *sql.Tx) { - if err = tx.Commit(); err != nil { - tx.Rollback() - } - slog.Debug("ended db transaction", slog.String("method", "MailIsTaken")) - }(tx) - - var rows *sql.Rows - if rows, err = tx.Query(`SELECT email FROM user_email_table WHERE email=? LIMIT 1;`, email); err != nil { - tx.Rollback() - - return false, fmt.Errorf("failed to query user-email settings in db: %s", err) - } - defer rows.Close() - - isTaken := rows.Next() - return isTaken, nil -} - -func (m *Mailer) GetInfo(userIdHash []byte) (email string, lang string, err error) { - tx, err := m.db.Begin() - if err != nil { - return "", "", fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "GetInfo")) - defer func(tx *sql.Tx) { - if err = tx.Commit(); err != nil { - tx.Rollback() - } - slog.Debug("ended db transaction", slog.String("method", "GetInfo")) - }(tx) - - var rows *sql.Rows - if rows, err = tx.Query(`SELECT email, lang FROM user_email_table WHERE user_id=? LIMIT 1;`, userIdHash); err != nil { - tx.Rollback() - return "", "", fmt.Errorf("failed to query user-email settings in db: %s", err) - } - defer rows.Close() - - if !rows.Next() { - return "", "", nil - } - - if err = rows.Scan(&email, &lang); err != nil { - return "", "", fmt.Errorf("failed to scan the result from user-email settings query: %s", err) - } - return -} - -func (m *Mailer) Unsubscribe(unsubscribeCodeString string) (clientError error, serverError error) { - unsubscribeCode, err := strconv.ParseUint(unsubscribeCodeString, 16, 64) - if err != nil { - return fmt.Errorf("invalid unsubscribe code: %s", err), nil - } - - userId, _ := m.unsubscribeCodes.Get(unsubscribeCode) - if len(userId) == 0 { - return fmt.Errorf("invalid unsubscribe code: have no information about it"), nil - } - - if err = m.Subscribe(userId, None); err != nil { - return nil, fmt.Errorf("failed to unsubscribe: %s", err) - } - - return nil, nil -} - -func (m *Mailer) SendVerificationCode(userId string, address string, lang string) error { - message := mail.NewMsg() - - if err := message.EnvelopeFrom(m.mailAddress); err != nil { - return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err) - } - if err := message.FromFormat(m.publicName, m.mailAddress); err != nil { - return fmt.Errorf("failed to set formatted FROM address: %w", err) - } - if err := message.To(address); err != nil { - return fmt.Errorf("failed to set TO address: %w", err) - } - - message.SetMessageID() - message.SetDate() - message.SetBulk() - - dur := 1 * time.Minute - m.lostMailMapMutex.Lock() - if previous, ok := m.lostMailMap[userId]; ok { - if time.Now().Before(previous.End) { - m.lostMailMapMutex.Unlock() - return fmt.Errorf("user is not allowed to send another verification code until %s", previous.End) - } - dur = 2 * m.lostMailMap[userId].Dur - } - m.lostMailMap[userId] = struct { - Dur time.Duration - End time.Time - CodeExpiry time.Time - }{Dur: dur, End: time.Now().Add(dur), CodeExpiry: time.Now().Add(time.Hour)} - m.lostMailMapMutex.Unlock() - - verificationCodeBytes := make([]byte, 8) - rand.Read(verificationCodeBytes) - verificationCode := binary.LittleEndian.Uint64(verificationCodeBytes) - - verificationInfo := fmt.Sprintf("%s.%s", base64.RawStdEncoding.EncodeToString([]byte(userId)), base64.RawStdEncoding.EncodeToString([]byte(address))) - m.verificationCodes.Set(verificationCode, verificationInfo, int64(len(verificationInfo)+8)) - - message.Subject(m.l[lang].Mail.VerifyEmail.Subject) - - msg, err := m.tm.Render("verify-email", fiber.Map{ - "L": m.l[lang], - "Lang": lang, - "VerificationCode": fmt.Sprintf("%X", verificationCode), - "ClientHost": m.clientHost, - }) - if err != nil { - return fmt.Errorf("failed to render message body: %w", err) - } - - message.SetBodyString(mail.TypeTextHTML, string(msg)) - if err := m.mailClient.DialAndSend(message); err != nil { - return fmt.Errorf("failed to send verification code message: %w", err) - } - slog.Debug("verification code message successfully delivered", slog.String("address", address), slog.String("user_id", userId)) - return nil -} - -func (m *Mailer) Verify(verificationCodeEncoded string, lang string) error { - verificationCode, err := strconv.ParseUint(verificationCodeEncoded, 16, 64) - if err != nil { - return fmt.Errorf("failed to decode verification code from 8-byte hex: %w", err) - } - - verificationInfo, _ := m.verificationCodes.Get(verificationCode) - if verificationInfo == "" { - return fmt.Errorf("failed to get verification info by its code (might be absent, might be empty)") - } - - verificationSegments := strings.Split(verificationInfo, ".") - if len(verificationSegments) != 2 { - m.verificationCodes.Del(verificationCode) - return fmt.Errorf("invalid format of verification info: expected %d segments, got %d", 2, len(verificationSegments)) - } - - userId, err := base64.RawStdEncoding.DecodeString(verificationSegments[0]) - if err != nil { - m.verificationCodes.Del(verificationCode) - delete(m.lostMailMap, verificationSegments[0]) - return fmt.Errorf("could not decode user id: %s", err) - } - - address, err := base64.RawStdEncoding.DecodeString(verificationSegments[1]) - if err != nil { - m.verificationCodes.Del(verificationCode) - delete(m.lostMailMap, verificationSegments[0]) - return fmt.Errorf("could not decode address: %s", err) - } - - tx, err := m.db.Begin() - if err != nil { - return fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "Verify")) - if _, err = tx.Exec(`INSERT INTO user_email_table(user_id, email, lang) VALUES(?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET - email=excluded.email, - lang=excluded.lang;`, m.GetHash(string(userId)), address, lang); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "Verify")) - return fmt.Errorf("failed to configure user-email settings in db: %s", err) - } - - if err = tx.Commit(); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "Verify")) - return fmt.Errorf("failed to commit transaction to db: %s", err) - } - slog.Debug("ended db transaction", slog.String("method", "Verify")) - - m.verificationCodes.Del(verificationCode) - delete(m.lostMailMap, verificationSegments[0]) - return nil -} - -func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionType, tags []string, err error) { - tx, err := m.db.Begin() - if err != nil { - return None, nil, fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "GetSubscriptions")) - defer func(tx *sql.Tx) { - if err = tx.Commit(); err != nil { - tx.Rollback() - } - slog.Debug("ended db transaction", slog.String("method", "GetSubscriptions")) - }(tx) - - hash := m.GetHash(userId) - - var rows *sql.Rows - if rows, err = tx.Query(`SELECT tags FROM subscription_user_to_tags_table WHERE user_id=? LIMIT 1;`, hash); err != nil { - tx.Rollback() - return None, nil, fmt.Errorf("failed to query user-to-tags table in db for the user: %s", err) - } - defer rows.Close() - - if !rows.Next() { - return None, nil, nil - } - - tagsString := "" - if err = rows.Scan(&tagsString); err != nil { - return None, nil, fmt.Errorf("failed to scan the result from user-to-tags query: %s", err) - } - - switch tagsString { - case "": - return None, nil, nil - case "_all": - return All, nil, nil - default: - return Specific, strings.Split(tagsString, ","), nil - } -} - -func (m *Mailer) Subscribe(userIdHash []byte, subscriptionType SubscriptionType, tags ...string) error { - tx, err := m.db.Begin() - if err != nil { - return fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "Subscribe")) - - slices.Sort(tags) - tagsOutput := "" - switch subscriptionType { - case All: - tagsOutput = "_all" - case None: - tagsOutput = "" - case Specific: - tagsOutput = strings.Join(tags, ",") - } - - if _, err = tx.Exec(`INSERT INTO subscription_user_to_tags_table(user_id, tags) VALUES(?, ?) - ON CONFLICT(user_id) DO UPDATE SET - tags=excluded.tags;`, userIdHash, tagsOutput); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "Subscribe")) - return fmt.Errorf("failed to configure user-to-tags table in db for the user: %s", err) - } - - if err = tx.Commit(); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "Subscribe")) - return fmt.Errorf("failed to commit transaction to db: %s", err) - } - slog.Debug("ended db transaction", slog.String("method", "Subscribe")) - - return nil -} - -func (m *Mailer) NewPost(post *b2.BlogPage) error { - tx, err := m.db.Begin() - if err != nil { - return fmt.Errorf("failed to initialize transaction with db: %s", err) - } - - slog.Debug("began db transaction", slog.String("method", "NewPost")) - var rows *sql.Rows - if rows, err = tx.Query(`SELECT user_id, tags FROM subscription_user_to_tags_table;`); err != nil { - tx.Rollback() - slog.Debug("ended db transaction", slog.String("method", "NewPost")) - return fmt.Errorf("failed to query user-to-tags table in db: %s", err) - } - - var userId []byte - usersToSend := make([]struct { - userId []byte - email string - }, 0) - var tagsString string - i := -1 - -rowLoop: - for rows.Next() { - i++ - if err = rows.Scan(&userId, &tagsString); err != nil { - slog.Warn("failed to scan a row in user-to-tags table", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId))) - continue - } - - if tagsString == "" { - continue - } - - if tagsString == "_all" { - usersToSend = append(usersToSend, struct { - userId []byte - email string - }{userId, ""}) - continue - } - - pageTags := strings.Split(tagsString, ",") - for _, tag := range pageTags { - if _, found := slices.BinarySearch(post.Metadata.Tags, tag); found { - usersToSend = append(usersToSend, struct { - userId []byte - email string - }{userId, ""}) - continue rowLoop - } - } - } - - tx.Commit() - rows.Close() - slog.Debug("ended db transaction", slog.String("method", "NewPost")) - - for i := range usersToSend { - email, lang, err := m.GetInfo(usersToSend[i].userId) - if err != nil { - slog.Warn("failed to get info about the user", slog.String("error", err.Error()), slog.Int("index", i), slog.String("user_id", base64.RawStdEncoding.EncodeToString(userId))) - continue - } - - if lang != post.Lang { - continue - } - - usersToSend[i].email = email - } - - messages := make([]*mail.Msg, 0, len(usersToSend)) - for _, user := range usersToSend { - if user.email == "" { - continue - } - unsubscribeCodeBytes := make([]byte, 8) - rand.Read(unsubscribeCodeBytes) - unsubscribeCode := binary.LittleEndian.Uint64(unsubscribeCodeBytes) - - unsubscribeFooter := strings.Replace(m.l[post.Lang].Mail.UnsubscribeFooter, "{}", fmt.Sprintf(`<a style="color: #273de1 !important;" href="https://%s/%s/user/unsubscribe?code=%X">`, m.clientHost, post.Lang, unsubscribeCode), 1) - unsubscribeFooter = strings.Replace(unsubscribeFooter, "{/}", "</a>", 1) - - msgBody, err := m.tm.Render("new-post", fiber.Map{ - "L": m.l[post.Lang], - "Lang": post.Lang, - "Post": post, - "ClientHost": m.clientHost, - "UnsubscribeFooter": template.HTML(unsubscribeFooter), - }) - - if err != nil { - return fmt.Errorf("failed to render message body: %w", err) - } - - message := mail.NewMsg() - - if err := message.EnvelopeFrom(m.mailAddress); err != nil { - return fmt.Errorf("failed to set ENVELOPE FROM address: %w", err) - } - if err := message.FromFormat(m.publicName, m.mailAddress); err != nil { - return fmt.Errorf("failed to set formatted FROM address: %w", err) - } - if err := message.To(user.email); err != nil { - return fmt.Errorf("failed to set TO address: %w", err) - } - - message.SetMessageID() - message.SetDate() - message.SetBulk() - message.Subject(m.l[post.Lang].Mail.NewPost.Subject) - message.SetBodyString(mail.TypeTextHTML, string(msgBody)) - - m.unsubscribeCodes.Set(unsubscribeCode, user.userId, 40) - - messages = append(messages, message) - } - - if err := m.mailClient.DialAndSend(messages...); err != nil { - return fmt.Errorf("failed to send new post notifications: %w", err) - } - return nil -} diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go deleted file mode 100644 index 5b1d14d..0000000 --- a/internal/router/api-v1-blog-search.go +++ /dev/null @@ -1,128 +0,0 @@ -package router - -import ( - "encoding/json" - "fmt" - "log/slog" - "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/locale" - "github.com/gofiber/fiber/v2" -) - -func init() { - 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 []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") - - 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)) - } - - 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() - 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) - - tags := make([]string, 0, len(matches)) - for _, match := range matches { - tags = append(tags, string(match[1])) - } - - pageMeta := make([]fiber.Map, 0, len(pages)) - for _, page := range pages { - for _, tag := range page.Metadata.Tags { - if len(tags) == 0 || slices.Contains(tags, tag) { - pageMeta = append(pageMeta, fiber.Map{ - "Link": page.Link, - "ArticleLink": "/" + lang + "/blog/" + page.FileName, - "Title": page.Metadata.Title, - "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"), - "ActionDate": page.Metadata.ActionDate, - "ShortDescription": page.Metadata.ShortDescription, - "Thumbnail": page.Metadata.Thumbnail, - "Tags": page.Metadata.Tags, - "LikeCount": CCache.GetLikeCount(page.FileName), - "ViewCount": CCache.GetViewCount(page.FileName), - }) - break - } - } - } - - slices.SortFunc(pageMeta, func(a, b fiber.Map) int { - switch sort { - case "titleAsc": - return strings.Compare(a["Title"].(string), b["Title"].(string)) - case "titleDesc": - return strings.Compare(b["Title"].(string), a["Title"].(string)) - case "actionDateAsc": - return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string)) - case "actionDateDesc": - return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string)) - case "publicationDateAsc": - publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string)) - publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string)) - return publishedTimeA.Compare(publishedTimeB) - case "publicationDateDesc": - publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string)) - publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string)) - return publishedTimeB.Compare(publishedTimeA) - } - return 0 - }) - - content, err := tm.Render("catalogue-blog-cards", fiber.Map{ - "BlogPages": pageMeta, - "L": l[lang], - }) - if err != nil { - slog.Warn("failed to generate div", 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 div") - } - - return c.Type("html").Send(content) - } -} diff --git a/internal/router/api-v1-email-is-in-verification.go b/internal/router/api-v1-email-is-in-verification.go deleted file mode 100644 index 83c783c..0000000 --- a/internal/router/api-v1-email-is-in-verification.go +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 9372eb3..0000000 --- a/internal/router/api-v1-email-send-verification-code.go +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 7fbfdec..0000000 --- a/internal/router/api-v1-email-verify.go +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index b5567b7..0000000 --- a/internal/router/api-v1-general-page-body.go +++ /dev/null @@ -1,243 +0,0 @@ -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 deleted file mode 100644 index b408187..0000000 --- a/internal/router/api-v1-general-page-bottom-embeds.go +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 99d899a..0000000 --- a/internal/router/api-v1-general-page-footer.go +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 768417d..0000000 --- a/internal/router/api-v1-general-page-header.go +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index f3e480b..0000000 --- a/internal/router/api-v1-general-page-top-embeds.go +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index c8b4362..0000000 --- a/internal/router/api-v1-general-page.go +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 323c457..0000000 --- a/internal/router/api-v1-like.go +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index 72f16e0..0000000 --- a/internal/router/api-v1-subs.go +++ /dev/null @@ -1,67 +0,0 @@ -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/api-v1-tz.go b/internal/router/api-v1-tz.go deleted file mode 100644 index 4e4e462..0000000 --- a/internal/router/api-v1-tz.go +++ /dev/null @@ -1,37 +0,0 @@ -package router - -import ( - "log/slog" - "time" - - "github.com/gofiber/fiber/v2" -) - -func Api_V1_TZ() func(c *fiber.Ctx) error { - return func(c *fiber.Ctx) error { - timestampString := c.Query("timestamp") - tz := c.Query("tz") - - 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)) - } - - var format string - var timestamp time.Time - for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} { - if timestamp, err = time.Parse(format, timestampString); err == nil { - break - } - } - - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - - if err != nil { - return c.Status(fiber.StatusBadRequest).SendString("invalid timestamp format") - } - - return c.Status(fiber.StatusOK).SendString(timestamp.In(loc).Format(format)) - } -} diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go deleted file mode 100644 index e7cda30..0000000 --- a/internal/router/client-cache.go +++ /dev/null @@ -1,320 +0,0 @@ -package router - -import ( - "database/sql" - "encoding/base64" - "fmt" - "log/slog" - "strings" - "sync" - - "golang.org/x/crypto/argon2" -) - -type ClientCache struct { - 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(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) 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 { - c.hashMapMutex.RUnlock() - slog.Debug("gave an old hash", slog.String("hash", val)) - return val - } - c.hashMapMutex.RUnlock() - - c.hashMapMutex.Lock() - defer c.hashMapMutex.Unlock() - - if val, ok := c.hashMap[id]; ok { - slog.Debug("gave a newly generated hash", slog.String("hash", val)) - return val - } - - 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-map.go b/internal/router/lang-map.go deleted file mode 100644 index 619b05b..0000000 --- a/internal/router/lang-map.go +++ /dev/null @@ -1,88 +0,0 @@ -package router - -import ( - "fmt" - "log/slog" - "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() { - assert(0, tm.Add("global-map", "views/pages/global-map.html")) -} - -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") - 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 { - status = fiber.StatusPartialContent - pages = []*b2.BlogPage{} - } - - type MapMarker struct { - Title string `json:"Title"` - PageLink string `json:"PageLink"` - Lat float64 `json:"Lat"` - Long float64 `json:"Long"` - AccuracyMeters int64 `json:"AccuracyMeters"` - Thumbnail string `json:"Thumbnail"` - } - - mapMarkers := make([]*MapMarker, 0, len(pages)) - for _, page := range pages { - geolocationParts := strings.Split(page.Metadata.Geolocation, " ") - if len(geolocationParts) < 2 { - continue - } - - var x, y float64 - var areaError int64 - if len(geolocationParts) >= 2 { - x, _ = strconv.ParseFloat(geolocationParts[0], 64) - y, _ = strconv.ParseFloat(geolocationParts[1], 64) - } - if len(geolocationParts) >= 3 { - areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64) - } - - mapMarkers = append(mapMarkers, &MapMarker{ - Title: page.Metadata.Title, - PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName), - Lat: x, - Long: y, - AccuracyMeters: areaError, - Thumbnail: page.Metadata.Thumbnail, - }) - } - - content, err := tm.Render("global-map", fiber.Map{ - "Lang": lang, - "L": l[lang], - "MapLocationLat": 45.4507, - "MapLocationLong": 68.8319, - "MapMarkers": mapMarkers, - }) - if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/map"), 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-user-unsubscribe.go b/internal/router/lang-user-unsubscribe.go deleted file mode 100644 index c39f5e7..0000000 --- a/internal/router/lang-user-unsubscribe.go +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 01e4792..0000000 --- a/internal/router/page-cache.go +++ /dev/null @@ -1,5 +0,0 @@ -package router - -import "github.com/dgraph-io/ristretto/v2" - -var PCache *ristretto.Cache[string, []byte] diff --git a/internal/router/tm.go b/internal/router/tm.go deleted file mode 100644 index e8095ee..0000000 --- a/internal/router/tm.go +++ /dev/null @@ -1,18 +0,0 @@ -package router - -import ( - "log/slog" - "os" - - "github.com/SayaAndy/saya-today-web/internal/templatemanager" -) - -var tm = assert(templatemanager.NewTemplateManager()) - -func assert[T any](t T, err error) T { - if err != nil { - slog.Error("fail to initialize template manager", slog.String("error", err.Error())) - os.Exit(1) - } - return t -} diff --git a/internal/tailwind/extension.go b/internal/tailwind/extension.go deleted file mode 100644 index c3254cc..0000000 --- a/internal/tailwind/extension.go +++ /dev/null @@ -1,21 +0,0 @@ -package tailwind - -import ( - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/util" -) - -type TailwindExtension struct{} - -func NewTailwindExtension() goldmark.Extender { - return &TailwindExtension{} -} - -func (e *TailwindExtension) Extend(m goldmark.Markdown) { - m.Parser().AddOptions( - parser.WithASTTransformers( - util.Prioritized(&TailwindTransformer{}, 500), - ), - ) -} diff --git a/internal/tailwind/link_renderer.go b/internal/tailwind/link_renderer.go deleted file mode 100644 index ff3d437..0000000 --- a/internal/tailwind/link_renderer.go +++ /dev/null @@ -1,49 +0,0 @@ -package tailwind - -import ( - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/renderer/html" - "github.com/yuin/goldmark/util" -) - -type CustomLinkRenderer struct { - html.Config -} - -func NewCustomLinkRenderer(opts ...html.Option) renderer.NodeRenderer { - r := &CustomLinkRenderer{ - Config: html.NewConfig(), - } - for _, opt := range opts { - opt.SetHTMLOption(&r.Config) - } - return r -} - -func (r *CustomLinkRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - n := node.(*ast.Link) - if entering { - _, _ = w.WriteString("<a href=\"") - if r.Unsafe || !html.IsDangerousURL(n.Destination) { - _, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true))) - } - _, _ = w.WriteString(`"`) - if n.Title != nil { - _, _ = w.WriteString(` title="`) - _, _ = w.Write(util.EscapeHTML(n.Title)) - _, _ = w.WriteString(`"`) - } - if n.Attributes() != nil { - html.RenderAttributes(w, n, nil) - } - _, _ = w.WriteString(">") - } else { - _, _ = w.WriteString("</a>") - } - return ast.WalkContinue, nil -} - -func (r *CustomLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(ast.KindLink, r.renderLink) -} diff --git a/internal/tailwind/transformer.go b/internal/tailwind/transformer.go deleted file mode 100644 index c53ded1..0000000 --- a/internal/tailwind/transformer.go +++ /dev/null @@ -1,79 +0,0 @@ -package tailwind - -import ( - "bytes" - "fmt" - - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/text" -) - -type TailwindTransformer struct{} - -func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { - ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering { - return ast.WalkContinue, nil - } - - switch node := n.(type) { - case *ast.Heading: - classes := map[int]string{ - 1: "font-andika text-4xl font-bold text-main-hard mb-3 tracking-[.0125rem]", - 2: "font-andika text-3xl font-bold text-main-hard mb-1 tracking-[.0125rem]", - 3: "font-andika text-2xl font-medium text-main-hard mb-0.8 tracking-[.0125rem]", - 4: "font-andika text-xl font-medium text-main-medium mb-0.5 tracking-[.0125rem]", - 5: "font-andika text-base font-medium text-main-medium mb-0.5 italic tracking-[.0125rem]", - 6: "font-andika text-base font-medium text-secondary mb-0.5", - } - if class, ok := classes[node.Level]; ok { - node.SetAttribute([]byte("class"), []byte(class)) - } - - case *ast.Paragraph: - node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem] -indent-8 ml-4 mb-8")) - - case *ast.List: - if node.IsOrdered() { - node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-2 mb-4 pl-8")) - } else { - node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-2 mb-4 pl-8")) - } - - case *ast.ListItem: - node.SetAttribute([]byte("class"), []byte("text-base/[2] font-andika tracking-[.0125rem]")) - - case *ast.Blockquote: - node.SetAttribute([]byte("class"), []byte("border-l-2 border-main-medium bg-background-dark p-1 mb-2 italic")) - - case *ast.CodeSpan: - node.SetAttribute([]byte("class"), []byte("bg-background-dark")) - - case *ast.CodeBlock, *ast.FencedCodeBlock: - node.SetAttribute([]byte("class"), []byte("bg-background-dark p-1 rounded-lg overflow-x-auto mb-4")) - - case *ast.Link: - if bytes.HasPrefix(node.Destination, []byte{'.', '/'}) { - onclickAttr := fmt.Appendf(make([]byte, 0, 21+len(node.Destination)), "return changeUrl('%s');", node.Destination) - node.SetAttribute([]byte("onclick"), onclickAttr) - } - node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-hard underline")) - - case *ast.Image: - node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-4")) - - case *ast.Emphasis: - switch node.Level { - case 1: - node.SetAttribute([]byte("class"), []byte("italic")) - case 2: - node.SetAttribute([]byte("class"), []byte("font-bold")) - case 3: - node.SetAttribute([]byte("class"), []byte("italic font-bold")) - } - } - - return ast.WalkContinue, nil - }) -} diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go deleted file mode 100644 index bafdee5..0000000 --- a/internal/templatemanager/templatemanager.go +++ /dev/null @@ -1,100 +0,0 @@ -package templatemanager - -import ( - "bytes" - "fmt" - "html/template" - "path/filepath" - "strings" -) - -type TemplateManager struct { - templates map[string]templateManagerRender -} - -type templateManagerRender struct { - Main string - Tmpl *template.Template -} - -type TemplateManagerTemplates struct { - Name string - 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 - }, - "replace": strings.ReplaceAll, -} - -func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) { - templateMap := make(map[string]templateManagerRender) - - for _, tmplStruct := range templates { - tmpl := template.New(tmplStruct.Name).Funcs(templateFuncMap) - tmpl, err := tmpl.ParseFiles(tmplStruct.Files...) - if err != nil { - return nil, err - } - templateMap[tmplStruct.Name] = templateManagerRender{ - Main: filepath.Base(tmplStruct.Files[0]), - Tmpl: tmpl, - } - } - - return &TemplateManager{ - templates: templateMap, - }, nil -} - -func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byte, error) { - tmpl, exists := tm.templates[name] - if !exists { - return nil, fmt.Errorf("template %s is not found", name) - } - - var err error - var tempTmpl *template.Template - if len(files) == 0 { - tempTmpl = tmpl.Tmpl - } else { - tempTmpl, err = tmpl.Tmpl.Clone() - if err != nil { - return nil, fmt.Errorf("couldn't clone existing template for rendering: %w", err) - } - tempTmpl, err = tempTmpl.ParseFiles(files...) - if err != nil { - return nil, fmt.Errorf("couldn't include additional files in template rendering: %w", err) - } - } - - var buf bytes.Buffer - err = tempTmpl.ExecuteTemplate(&buf, tmpl.Main, data) - return buf.Bytes(), err -} - -func (tm *TemplateManager) Add(name string, files ...string) error { - if len(files) == 0 { - return fmt.Errorf("you can't add template without any files") - } - - tmpl := template.New(name).Funcs(templateFuncMap) - - tmpl, err := tmpl.ParseFiles(files...) - if err != nil { - return fmt.Errorf("failed to add template into manager: %w", err) - } - - tm.templates[name] = templateManagerRender{ - Main: filepath.Base(files[0]), - Tmpl: tmpl, - } - return nil -} |