Diffstat (limited to 'internal')
49 files changed, 870 insertions, 4255 deletions
diff --git a/internal/blog/b2.go b/internal/b2/client.go index f2ac890..7b745a8 100644 --- a/internal/blog/b2.go +++ b/internal/b2/client.go @@ -1,10 +1,8 @@ -package blog +package b2 import ( "context" - "encoding/json" "fmt" - "slices" "strings" "time" @@ -19,41 +17,28 @@ type B2Client struct { b2cl *b2.Client } -func NewB2Client(cfg *config.StorageConfig) (Client, error) { - if cfg.Type != "b2" { - return nil, fmt.Errorf("invalid storage type for B2InputClient") - } - b2cfg := cfg.Config.(*config.B2Config) - - b2cl, err := b2.NewClient(context.Background(), b2cfg.KeyID, b2cfg.ApplicationKey) +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(), b2cfg.BucketName) + bucket, err := b2cl.Bucket(context.Background(), cfg.BucketName) if err != nil { return nil, err } - return &B2Client{b2cl: b2cl, bucket: bucket, prefix: b2cfg.Prefix}, nil + return &B2Client{b2cl: b2cl, bucket: bucket, prefix: cfg.Prefix}, nil } -func (c *B2Client) GetMedleys() ([]MedleyEntry, error) { - idxRaw, err := c.readAll(MedleysIndexFileName) - if err != nil { - return nil, fmt.Errorf("read %s: %w", MedleysIndexFileName, err) - } - - var idx []MedleyEntry - if err := json.Unmarshal(idxRaw, &idx); err != nil { - return nil, fmt.Errorf("unmarshal %s: %w", MedleysIndexFileName, err) - } - - return idx, nil +type BlogPage struct { + Link string + FileName string + Metadata *frontmatter.Metadata } -func (c *B2Client) Scan(prefix string) ([]*Page, error) { - filePaths := []*Page{} +func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { + filePaths := []*BlogPage{} iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix+prefix)) @@ -85,25 +70,20 @@ func (c *B2Client) Scan(prefix string) ([]*Page, error) { return nil, fmt.Errorf("failed to parse published time metadata field: %w", err) } - link := obj.Name() - fileName := link[strings.LastIndex(link, "/")+1 : strings.LastIndex(link, ".")] - tags := strings.Split(attrs.Info["tags"], ",") - slices.Sort(tags) - - lang, _ := strings.CutPrefix(link[0:strings.Index(link, "/")], c.prefix) + linkParts := strings.Split(obj.Name(), "/") + nameParts := strings.Split(linkParts[len(linkParts)-1], ".") + fileName := strings.Join(nameParts[:len(linkParts)-1], ".") - filePaths = append(filePaths, &Page{ - Link: link, - FileName: fileName, - Lang: lang, - ModifiedTime: attrs.LastModified, + 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, + Tags: strings.Split(attrs.Info["tags"], ","), Geolocation: attrs.Info["geolocation"], }, }) @@ -117,11 +97,7 @@ func (c *B2Client) Scan(prefix string) ([]*Page, error) { } func (c *B2Client) ReadAll(path string) ([]byte, error) { - return c.readAll(c.prefix + path) -} - -func (c *B2Client) readAll(path string) ([]byte, error) { - obj := c.bucket.Object(path) + obj := c.bucket.Object(c.prefix + path) if obj == nil { return nil, fmt.Errorf("failed to reference object in B2 bucket") } diff --git a/internal/blog/client.go b/internal/blog/client.go deleted file mode 100644 index 53eb0fd..0000000 --- a/internal/blog/client.go +++ /dev/null @@ -1,28 +0,0 @@ -package blog - -import ( - "time" - - "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/frontmatter" -) - -type Page struct { - Link string - FileName string - Lang string - ModifiedTime time.Time - Metadata *frontmatter.Metadata -} - -type Client interface { - Scan(prefix string) ([]*Page, error) - GetMedleys() ([]MedleyEntry, error) - ReadAll(path string) ([]byte, error) - ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) -} - -var NewClientMap = map[string]func(*config.StorageConfig) (Client, error){ - "b2": NewB2Client, - "s3": NewS3Client, -} diff --git a/internal/blog/index.go b/internal/blog/index.go deleted file mode 100644 index a09b59e..0000000 --- a/internal/blog/index.go +++ /dev/null @@ -1,103 +0,0 @@ -package blog - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/SayaAndy/saya-today-web/internal/frontmatter" -) - -const IndexFileName = "index.json" -const MedleysIndexFileName = "medleys.json" - -const IndexSchemaVersion = 2 - -type IndexEntry struct { - Link string `json:"link"` - ModifiedTime time.Time `json:"modifiedTime"` - Title string `json:"title"` - ShortDescription string `json:"shortDescription"` - ActionDate string `json:"actionDate"` - PublishedTime time.Time `json:"publishedTime"` - Thumbnail string `json:"thumbnail"` - Tags []string `json:"tags"` - Geolocation string `json:"geolocation"` - Medley string `json:"medley,omitempty"` - MedleyPart int `json:"medleyPart,omitempty"` -} - -func (e IndexEntry) Metadata() *frontmatter.Metadata { - return &frontmatter.Metadata{ - Title: e.Title, - ShortDescription: e.ShortDescription, - ActionDate: e.ActionDate, - PublishedTime: e.PublishedTime, - Thumbnail: e.Thumbnail, - Tags: e.Tags, - Geolocation: e.Geolocation, - Medley: e.Medley, - MedleyPart: e.MedleyPart, - } -} - -type IndexV2Category struct { - GeneratedAt time.Time `json:"generatedAt"` - Pages map[string]IndexEntry `json:"pages"` -} - -type IndexV1Category struct { - GeneratedAt time.Time `json:"generatedAt"` - Pages []IndexEntry `json:"pages"` -} - -type Index struct { - SchemaVersion int `json:"schemaVersion"` - GeneratedAt time.Time `json:"generatedAt"` - Categories any `json:"categories"` -} - -func (idx *Index) UnmarshalJSON(data []byte) error { - var tmp struct { - SchemaVersion int `json:"schemaVersion"` - GeneratedAt time.Time `json:"generatedAt"` - Categories json.RawMessage `json:"categories"` - } - - if err := json.Unmarshal(data, &tmp); err != nil { - return err - } - - idx.SchemaVersion = tmp.SchemaVersion - idx.GeneratedAt = tmp.GeneratedAt - - switch tmp.SchemaVersion { - case 1: - var categories map[string]*IndexV1Category - if err := json.Unmarshal(tmp.Categories, &categories); err != nil { - return fmt.Errorf("unmarshal map[string]*IndexV1Category: %w", err) - } - idx.Categories = &categories - case 2: - var categories map[string]*IndexV2Category - if err := json.Unmarshal(tmp.Categories, &categories); err != nil { - return fmt.Errorf("unmarshal map[string]*IndexV2Category: %w", err) - } - idx.Categories = &categories - default: - return fmt.Errorf("unsupported index version: %d", tmp.SchemaVersion) - } - - return nil -} - -type MedleyEntry struct { - Codename string `json:"codename"` - Localnames map[string]string `json:"localnames"` - Content []string `json:"content"` -} - -type MedleyPageEntry struct { - Codename string `json:"codename"` - Position int `json:"position"` -} diff --git a/internal/blog/s3.go b/internal/blog/s3.go deleted file mode 100644 index 3c1a932..0000000 --- a/internal/blog/s3.go +++ /dev/null @@ -1,241 +0,0 @@ -package blog - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/frontmatter" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/aws/aws-sdk-go-v2/aws" - awsconfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" -) - -type S3Client struct { - prefix string - bucketName string - s3cl *s3.Client -} - -func NewS3Client(cfg *config.StorageConfig) (Client, error) { - if cfg.Type != "s3" { - return nil, fmt.Errorf("invalid storage type for S3Client") - } - s3cfg := cfg.Config.(*config.S3Config) - - opts := []func(*awsconfig.LoadOptions) error{ - awsconfig.WithRegion(s3cfg.Region), - } - if s3cfg.AccessKeyID != "" && s3cfg.SecretAccessKey != "" { - opts = append(opts, awsconfig.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider(s3cfg.AccessKeyID, s3cfg.SecretAccessKey, ""), - )) - } - - awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...) - if err != nil { - return nil, fmt.Errorf("load AWS config: %w", err) - } - - var s3Opts []func(*s3.Options) - if s3cfg.Endpoint != "" { - s3Opts = append(s3Opts, func(o *s3.Options) { - o.BaseEndpoint = aws.String(s3cfg.Endpoint) - }) - } - s3Opts = append(s3Opts, func(o *s3.Options) { - o.UsePathStyle = s3cfg.UsePathStyle - o.DisableLogOutputChecksumValidationSkipped = true - }) - - s3cl := s3.NewFromConfig(awsCfg, s3Opts...) - - return &S3Client{s3cfg.Prefix, s3cfg.BucketName, s3cl}, nil -} - -func (c *S3Client) GetMedleys() ([]MedleyEntry, error) { - idxRaw, err := c.readAll(MedleysIndexFileName) - if err != nil { - return nil, fmt.Errorf("read %s: %w", MedleysIndexFileName, err) - } - - var idx []MedleyEntry - if err := json.Unmarshal(idxRaw, &idx); err != nil { - return nil, fmt.Errorf("unmarshal %s: %w", MedleysIndexFileName, err) - } - - return idx, nil -} - -func (c *S3Client) Scan(prefix string) ([]*Page, error) { - out, err := c.s3cl.GetObject(context.Background(), &s3.GetObjectInput{ - Bucket: aws.String(c.bucketName), - Key: aws.String(IndexFileName), - }) - if err != nil { - return nil, fmt.Errorf("get %s: %w", IndexFileName, err) - } - defer out.Body.Close() - - raw, err := io.ReadAll(out.Body) - if err != nil { - return nil, fmt.Errorf("read %s: %w", IndexFileName, err) - } - - var idx Index - if err := json.Unmarshal(raw, &idx); err != nil { - return nil, fmt.Errorf("unmarshal %s: %w", IndexFileName, err) - } - - wantLang := "" - if i := strings.Index(prefix, "/"); i > 0 { - wantLang = prefix[:i] - } - - fullPrefix := c.prefix + prefix - pages := make([]*Page, 0) - - switch idx.SchemaVersion { - case 1: - for catKey, cat := range *idx.Categories.(*map[string]*IndexV1Category) { - lang, ok := strings.CutPrefix(catKey, c.prefix) - if !ok { - continue - } - if wantLang != "" && wantLang != lang { - continue - } - for _, e := range cat.Pages { - if !strings.HasPrefix(e.Link, fullPrefix) { - continue - } - fileName := e.Link[strings.LastIndex(e.Link, "/")+1 : strings.LastIndex(e.Link, ".")] - pages = append(pages, &Page{ - Link: e.Link, - FileName: fileName, - Lang: lang, - ModifiedTime: e.ModifiedTime, - Metadata: &frontmatter.Metadata{ - Title: e.Title, - ShortDescription: e.ShortDescription, - ActionDate: e.ActionDate, - PublishedTime: e.PublishedTime, - Thumbnail: e.Thumbnail, - Tags: e.Tags, - Geolocation: e.Geolocation, - Medley: e.Medley, - MedleyPart: e.MedleyPart, - }, - }) - } - } - case 2: - for catKey, cat := range *idx.Categories.(*map[string]*IndexV2Category) { - lang, ok := strings.CutPrefix(catKey, c.prefix) - if !ok { - continue - } - if wantLang != "" && wantLang != lang { - continue - } - for codename, e := range cat.Pages { - if !strings.HasPrefix(e.Link, fullPrefix) { - continue - } - pages = append(pages, &Page{ - Link: e.Link, - FileName: codename, - Lang: lang, - ModifiedTime: e.ModifiedTime, - Metadata: &frontmatter.Metadata{ - Title: e.Title, - ShortDescription: e.ShortDescription, - ActionDate: e.ActionDate, - PublishedTime: e.PublishedTime, - Thumbnail: e.Thumbnail, - Tags: e.Tags, - Geolocation: e.Geolocation, - Medley: e.Medley, - MedleyPart: e.MedleyPart, - }, - }) - } - } - } - - medleys, _ := c.GetMedleys() - for _, medley := range medleys { - for locale, localname := range medley.Localnames { - l10n.T.SetPath(localname, true, locale, "Medleys", medley.Codename) - } - } - - return pages, nil -} - -func (c *S3Client) ReadAll(path string) ([]byte, error) { - return c.readAll(c.prefix + path) -} - -func (c *S3Client) readAll(path string) ([]byte, error) { - output, err := c.s3cl.GetObject(context.Background(), &s3.GetObjectInput{ - Bucket: aws.String(c.bucketName), - Key: aws.String(path), - }) - if err != nil { - return nil, fmt.Errorf("get S3 object: %w", err) - } - defer output.Body.Close() - - content, err := io.ReadAll(output.Body) - if err != nil { - return nil, fmt.Errorf("read S3 object body: %w", err) - } - - return content, nil -} - -func (c *S3Client) ReadFrontmatter(path string) (metadata *frontmatter.Metadata, markdown []byte, err error) { - idxRaw, err := c.readAll(IndexFileName) - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", IndexFileName, err) - } - - var idx Index - if err := json.Unmarshal(idxRaw, &idx); err != nil { - return nil, nil, fmt.Errorf("unmarshal %s: %w", IndexFileName, err) - } - - contentBytes, err := c.ReadAll(path) - if err != nil { - return nil, nil, fmt.Errorf("failed to read file for frontmatter parsing: %w", err) - } - - switch idx.SchemaVersion { - case 1: - return frontmatter.ParseFrontmatter(contentBytes) - case 2: - fullPath := c.prefix + path - page := (*idx.Categories.(*map[string]*IndexV2Category))[fullPath[:strings.LastIndex(fullPath, "/")]].Pages[fullPath[strings.LastIndex(fullPath, "/")+1:strings.LastIndex(fullPath, ".")]] - metadata = page.Metadata() - - if !bytes.HasPrefix(contentBytes, []byte("---\n")) { - return metadata, contentBytes, nil - } - - end := bytes.Index(contentBytes[4:], []byte("\n---\n")) - if end == -1 { - return metadata, contentBytes, nil - } - - return metadata, contentBytes[end+9:], nil - } - - return frontmatter.ParseFrontmatter(contentBytes) -} diff --git a/internal/blogtrigger/blogtrigger.go b/internal/blogtrigger/blogtrigger.go deleted file mode 100644 index 81efafe..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/blog" - "github.com/go-co-op/gocron/v2" -) - -type BlogTriggerScheduler struct { - s gocron.Scheduler - knownBlogPages map[string]map[string]*blog.Page - blogClient blog.Client - onTrigger func([]*blog.Page) error -} - -func NewBlogTriggerScheduler(blogClient blog.Client, availableLanguages []config.AvailableLanguageConfig, cron string, onTrigger func([]*blog.Page) 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]*blog.Page, len(availableLanguages)) - for _, lang := range availableLanguages { - knownBlogPages[lang.Name] = make(map[string]*blog.Page) - } - - bts := &BlogTriggerScheduler{s, knownBlogPages, blogClient, 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 []*blog.Page, err error) { - newPages = make([]*blog.Page, 0) - for lang := range bts.knownBlogPages { - posts, err := bts.blogClient.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 c5b8bc4..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/blog" -) - -type FactGiver struct { - blogClient blog.Client - 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) { - blogClient, err := blog.NewClientMap[cfg.Storage.Type](&cfg.Storage) - if err != nil { - return nil, fmt.Errorf("fail to init b2 client for a new fact giver: %s", err.Error()) - } - - factGiver := &FactGiver{ - blogClient: blogClient, - 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.blogClient.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 index 942760c..1a521c0 100644 --- a/internal/frontmatter/parser.go +++ b/internal/frontmatter/parser.go @@ -1,38 +1,33 @@ package frontmatter import ( - "bytes" "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"` - ContentSettings map[string]string `yaml:"contentSettings"` + 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"` } func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) { - if !bytes.HasPrefix(content, []byte("---\n")) { - return nil, content, nil - } + frontmatterRegex := regexp.MustCompile(`^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$`) + matches := frontmatterRegex.FindSubmatch(content) - end := bytes.Index(content[4:], []byte("\n---\n")) - if end == -1 { + if len(matches) != 3 { return nil, content, nil } - yamlContent := content[4 : end+4] - markdownContent := content[end+9:] + yamlContent := matches[1] + markdownContent := matches[2] metadata = &Metadata{} if err := yaml.Unmarshal([]byte(yamlContent), &metadata); err != nil { diff --git a/internal/glightbox/block.go b/internal/glightbox/block.go deleted file mode 100644 index 30cda1c..0000000 --- a/internal/glightbox/block.go +++ /dev/null @@ -1,33 +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 - Namespace string -} - -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 e9a9f3e..0000000 --- a/internal/glightbox/extension.go +++ /dev/null @@ -1,31 +0,0 @@ -package glightbox - -import ( - "github.com/SayaAndy/saya-today-web/config" - "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 { - photoStorage config.PhotoStorageConfig -} - -func NewGLightboxExtension(photoStorage config.PhotoStorageConfig) goldmark.Extender { - return &GLightboxExtension{photoStorage} -} - -func (e *GLightboxExtension) Extend(m goldmark.Markdown) { - m.Parser().AddOptions( - parser.WithBlockParsers( - util.Prioritized(NewGLightboxParser(), 500), - ), - ) - m.Renderer().AddOptions( - renderer.WithNodeRenderers( - util.Prioritized(NewGLightboxHTMLRenderer(e.photoStorage), 500), - ), - ) -} diff --git a/internal/glightbox/html_renderer.go b/internal/glightbox/html_renderer.go deleted file mode 100644 index 1bb606b..0000000 --- a/internal/glightbox/html_renderer.go +++ /dev/null @@ -1,202 +0,0 @@ -package glightbox - -import ( - "bytes" - "fmt" - "math/rand" - "regexp" - "strings" - "time" - - "github.com/SayaAndy/saya-today-web/config" - "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 - photoStorage config.PhotoStorageConfig -} - -func NewGLightboxHTMLRenderer(photoStorage config.PhotoStorageConfig, 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), - ), - ), - ), - ), - anchorMatchRe: regexp.MustCompile(`(?s)<\s*a(\s+[^<]*)(href\s*=\s*["'].*?["'])\s*([^<]*)>(.*?)<\s*\/\s*a\s*>`), - photoStorage: photoStorage, - } - for _, opt := range opts { - opt.SetHTMLOption(&r.Config) - } - 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 { - fullImageUrl := gallery.Namespace + img.URL - imageUrlSegments := strings.Split(fullImageUrl, ".") - 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="%[7]s" class="glightbox grid-item %[1]s grid-item-%[2]s p-1" - data-gallery="gallery" data-title="%[3]s" %[4]s> - <picture> - <source media="(width < 800px)" srcset="%[8]s" /> - <source media="(width < 2400px)" srcset="%[9]s" /> - <source media="(width < 3200px)" srcset="%[10]s" /> - <source media="(width < 4000px)" srcset="%[11]s" /> - <source media="(width >= 4000px)" srcset="%[12]s" /> - <img src="%[9]s" /> - </picture> - <span class="grid-tooltip-text"><p>%[5]s</p></span> - <span class="grid-item-index">%[6]d</span> - </a>`, - strings.Join(tagClassList, " "), - galleryID, - dayDate.Format("2006-01-02 15:04:05 -07:00"), - dataDescriptionAttribute, - anchorlessCaptionHTML, - i+1, - fmt.Sprintf(r.photoStorage.Full.BaseUrl, fullImageUrl), - fmt.Sprintf(r.photoStorage.Thumbnail1600p.BaseUrl, imageUrlWithoutExt), - fmt.Sprintf(r.photoStorage.Thumbnail1200p.BaseUrl, imageUrlWithoutExt), - fmt.Sprintf(r.photoStorage.Thumbnail800p.BaseUrl, imageUrlWithoutExt), - fmt.Sprintf(r.photoStorage.Thumbnail560p.BaseUrl, imageUrlWithoutExt), - fmt.Sprintf(r.photoStorage.Thumbnail320p.BaseUrl, imageUrlWithoutExt), - )) - - if glightboxDescId != "" { - elements = append(elements, fmt.Sprintf(` - <div class="glightbox-desc rounded-4 %s"> - <p>%s</p> - </div>`, glightboxDescId, captionHTML)) - } - } - - w.WriteString(strings.ReplaceAll(` -<div class="items-center flex flex-col"> - <hr class="border-t-[0.375rem] border-dotted border-main-hard my-2 w-24 ml-auto mr-auto"> - <div class="grid masonry-grid-{id}"> - <div class="grid-sizer grid-sizer-{id}"></div> - `+strings.Join(elements, "\n")+` - </div> - <hr class="border-t-[0.375rem] border-dotted border-main-hard my-2 w-24 ml-auto mr-auto"> -</div> - -<script> - var pckry_{id} = new Packery('.masonry-grid-{id}', { - itemSelector: '.grid-item-{id}', - columnWidth: '.grid-sizer-{id}', - percentPosition: false - }); - - var imgLoad_{id}_timer; - var imgLoad_{id} = imagesLoaded('.masonry-grid-{id}'); - - function initMasonryLayout_{id}(tm) { - clearTimeout(imgLoad_{id}_timer); - imgLoad_{id}_timer = setTimeout(() => pckry_{id}.layout(), 100); - } - - imgLoad_{id}.on('progress', initMasonryLayout_{id}); - - window.addEventListener('resize', initMasonryLayout_{id}); - - document.addEventListener('popout', (e) => { - window.removeEventListener('resize', initMasonryLayout_{id}); - imgLoad_{id}.off('progress', initMasonryLayout_{id}); - initMasonryLayout_{id} = null; - imgLoad_{id} = null; - }, {once: true}); -</script> -`, "{id}", 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/lightgallery/block.go b/internal/lightgallery/block.go new file mode 100644 index 0000000..8eb7c9e --- /dev/null +++ b/internal/lightgallery/block.go @@ -0,0 +1,31 @@ +package lightgallery + +import ( + "time" + + "github.com/yuin/goldmark/ast" +) + +// LightGalleryBlock represents a light gallery block in the AST +type LightGalleryBlock struct { + ast.BaseBlock + Images []LightGalleryImage + Location *time.Location +} + +type LightGalleryImage struct { + URL string + Caption []byte +} + +var KindLightGalleryBlock = ast.NewNodeKind("LightGalleryBlock") + +// Dump implements ast.Node.Dump +func (n *LightGalleryBlock) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// Kind implements ast.Node.Kind +func (n *LightGalleryBlock) Kind() ast.NodeKind { + return KindLightGalleryBlock +} diff --git a/internal/lightgallery/extension.go b/internal/lightgallery/extension.go new file mode 100644 index 0000000..761dc05 --- /dev/null +++ b/internal/lightgallery/extension.go @@ -0,0 +1,28 @@ +package lightgallery + +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 LightGalleryExtension struct{} + +func NewLightGalleryExtension() goldmark.Extender { + return &LightGalleryExtension{} +} + +func (e *LightGalleryExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithBlockParsers( + util.Prioritized(NewLightGalleryParser(), 500), + ), + ) + m.Renderer().AddOptions( + renderer.WithNodeRenderers( + util.Prioritized(NewLightGalleryHTMLRenderer(), 500), + ), + ) +} diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go new file mode 100644 index 0000000..e20e9ec --- /dev/null +++ b/internal/lightgallery/html_renderer.go @@ -0,0 +1,134 @@ +package lightgallery + +import ( + "bytes" + "fmt" + "math/rand" + "strings" + "time" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +type LightGalleryHTMLRenderer struct { + html.Config + md goldmark.Markdown +} + +func NewLightGalleryHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &LightGalleryHTMLRenderer{ + Config: html.NewConfig(), + md: goldmark.New( + goldmark.WithRendererOptions( + html.WithHardWraps(), + html.WithXHTML(), + ), + ), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + return r +} + +func (r *LightGalleryHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindLightGalleryBlock, r.renderLightGallery) +} + +func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + gallery := n.(*LightGalleryBlock) + + if len(gallery.Images) == 0 { + return ast.WalkContinue, nil + } + + galleryID := generateDivId(8) + + w.WriteString(fmt.Sprintf(` +<div class="justify-content-center display-block m-2"> + <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%%] ml-auto mr-auto"> + <div id="lg-%s" class="inline-gallery-container relative ml-auto mr-auto"></div> + <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%%] ml-auto mr-auto"> +</div>`, galleryID)) + + var dynamicElements []string + for _, img := range gallery.Images { + imageUrlSegments := strings.Split(img.URL, ".") + imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".") + imageNameParts := strings.Split(imageUrlWithoutExt, "-") + + 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) + dynamicElements = append(dynamicElements, fmt.Sprintf(`{ + src: + "https://f003.backblazeb2.com/file/sayana-photos/full/%s", + downloadUrl: + "https://f003.backblazeb2.com/file/sayana-photos/full/%s", + alt: "%s", + responsive: "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp 384, https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp 672, https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp 960, https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp 1440, https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp 1920", + thumb: + "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp", + subHtml: `+"`"+`<div class="flex flex-row light-gallery-captions"> + <p class="grow !text-[1vmax]/[1] text-left font-spectral text-main-dark">%s</p> + <p class="!text-[1vmax]/[1] text-right font-spectral text-secondary">%s</p> + </div>`+"`"+` + }`, img.URL, img.URL, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, dayDate.Format("2006-01-02 15:04:05 -07:00"))) + } + + w.WriteString(fmt.Sprintf(` +<script> +function createLightGallery%s() { + const $lgContainer = document.getElementById('lg-%s'); + const inlineGallery = lightGallery($lgContainer, { + container: $lgContainer, + dynamic: true, + dynamicEl: [%s], + width: "100%%", + height: "50vmin", + hash: false, + closable: false, + showMaximizeIcon: true, + appendSubHtmlTo: ".lg-sub-html", + isMobile: () => false, + slideDelay: 0, + plugins: [lgZoom, lgThumbnail], + thumbWidth: calculateVmin(10), + thumbHeight: "10vmin", + thumbMargin: 4 + }); + + setTimeout(() => { + inlineGallery.openGallery(); + }, 200); +} + +document.addEventListener('DOMContentLoaded', createLightGallery%s); +</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), 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/lightgallery/parser.go index b376034..daec797 100644 --- a/internal/glightbox/parser.go +++ b/internal/lightgallery/parser.go @@ -1,10 +1,9 @@ -package glightbox +package lightgallery import ( "bytes" "log/slog" "regexp" - "strings" "time" "github.com/yuin/goldmark/ast" @@ -12,24 +11,24 @@ import ( "github.com/yuin/goldmark/text" ) -type GLightboxParser struct{} +type LightGalleryParser struct{} -func NewGLightboxParser() parser.BlockParser { - return &GLightboxParser{} +func NewLightGalleryParser() parser.BlockParser { + return &LightGalleryParser{} } -func (p *GLightboxParser) Trigger() []byte { +func (p *LightGalleryParser) Trigger() []byte { return []byte{'{'} } -func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { +func (p *LightGalleryParser) 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\+\-/]+)(:([A-Za-z0-9\+\-/]+))?\}$`) + r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)\}$`) trimmed := bytes.TrimSpace(line) parts := r.FindSubmatch(trimmed) @@ -44,15 +43,10 @@ func (p *GLightboxParser) Open(parent ast.Node, reader text.Reader, pc parser.Co return nil, parser.NoChildren } - ns := "" - if len(parts) >= 4 { - ns = string(parts[3]) - } - - return &GLightboxBlock{Location: loc, Namespace: ns}, parser.NoChildren + return &LightGalleryBlock{Location: loc}, parser.NoChildren } -func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { +func (p *LightGalleryParser) 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 @@ -64,42 +58,31 @@ func (p *GLightboxParser) Continue(node ast.Node, reader text.Reader, pc parser. return parser.Close } - gallery := node.(*GLightboxBlock) + gallery := node.(*LightGalleryBlock) - parts := bytes.SplitN(trimmed, []byte{'|'}, 3) + parts := bytes.SplitN(trimmed, []byte{'|'}, 2) url := bytes.TrimSpace(parts[0]) caption := make([]byte, 0) - tagsRaw := "" - if len(parts) == 2 { + if len(parts) > 1 { 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{ + gallery.Images = append(gallery.Images, LightGalleryImage{ 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 *LightGalleryParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { } -func (p *GLightboxParser) CanInterruptParagraph() bool { +func (p *LightGalleryParser) CanInterruptParagraph() bool { return true } -func (p *GLightboxParser) CanAcceptIndentedLine() bool { +func (p *LightGalleryParser) CanAcceptIndentedLine() bool { return false } diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go deleted file mode 100644 index 33db4f7..0000000 --- a/internal/mailer/mailer.go +++ /dev/null @@ -1,540 +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/blog" - "github.com/SayaAndy/saya-today-web/internal/templatemanager" - "github.com/SayaAndy/saya-today-web/l10n" - "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 -} - -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) (*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), - }, 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(l10n.T.GetPath(lang, "Mail", "VerifyEmail", "Subject").(string)) - - msg, err := m.tm.Render("verify-email", fiber.Map{ - "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 *blog.Page) 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(l10n.T.GetPath(post.Lang, "Mail", "UnsubscribeFooter").(string), "{}", 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{ - "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(l10n.T.GetPath(post.Lang, "Mail", "NewPost", "Subject").(string)) - 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 new file mode 100644 index 0000000..a809783 --- /dev/null +++ b/internal/router/api-v1-blog-search.go @@ -0,0 +1,112 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html") +} + +func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + sort := c.Query("sort") + lang := c.Query("lang") + tz := c.Query("tz") + + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + } + + loc, err := time.LoadLocation(tz) + if err != nil { + loc = time.UTC + slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz)) + } + + pages, err := b2Client.Scan(lang + "/") + if err != nil { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err)) + } + + 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, + }) + 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-like.go b/internal/router/api-v1-like.go new file mode 100644 index 0000000..1732c77 --- /dev/null +++ b/internal/router/api-v1-like.go @@ -0,0 +1,117 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html") +} + +func Api_V1_Like_Put(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() + newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true")) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value") + } + + 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{ + "Liked": newLikeStatus, + }) + 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(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{ + "Liked": likeStatus, + }) + 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-tz.go b/internal/router/api-v1-tz.go new file mode 100644 index 0000000..4e4e462 --- /dev/null +++ b/internal/router/api-v1-tz.go @@ -0,0 +1,37 @@ +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/basic-handler.go b/internal/router/basic-handler.go deleted file mode 100644 index 68d305c..0000000 --- a/internal/router/basic-handler.go +++ /dev/null @@ -1,82 +0,0 @@ -package router - -import ( - "time" - - "github.com/gofiber/fiber/v2" -) - -type BasicHandler struct{} - -var _ Route = &BasicHandler{} - -func (r *BasicHandler) Filter() (method string, path string) { - panic("handler did not implement Filter method") -} - -func (r *BasicHandler) IsTemplated() bool { - return false -} - -func (r *BasicHandler) ToCache() CacheSetting { - return Disabled -} - -func (r *BasicHandler) CacheDuration() time.Duration { - return 5 * time.Minute -} - -func (r *BasicHandler) ToValidateLang() LangSetting { - return NotRequired -} - -func (r *BasicHandler) TemplatesToInject() []string { - return []string{} -} - -func (r *BasicHandler) SitemapInfo(supplements *Supplements) []SitemapInfo { - return nil -} - -func (r *BasicHandler) ContentType() string { - return fiber.MIMETextHTMLCharsetUTF8 -} - -func (r *BasicHandler) RateLimiter() *fiber.Handler { - return nil -} - -func (r *BasicHandler) Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - if !r.IsTemplated() { - panic("handler did not implement Render method (while being non-templated)") - } - return fiber.StatusNoContent, nil -} - -func (r *BasicHandler) AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta []MetaField, err error) { - return []MetaField{{Name: "robots", Content: "noindex,nofollow"}}, nil -} - -func (r *BasicHandler) AddLinkedData(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (ld map[string]any, err error) { - return nil, nil -} - -func (r *BasicHandler) RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - return fiber.StatusNoContent, nil -} - -func (r *BasicHandler) RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - return fiber.StatusNoContent, nil -} - -func (r *BasicHandler) RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - return fiber.StatusNoContent, nil -} - -func (r *BasicHandler) RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - return fiber.StatusNoContent, nil -} - -func (r *BasicHandler) RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - return fiber.StatusNoContent, nil -} diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index 7c28b9e..3104a66 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -1,11 +1,8 @@ package router import ( - "database/sql" "encoding/base64" - "fmt" "log/slog" - "strings" "sync" "golang.org/x/crypto/argon2" @@ -13,131 +10,35 @@ import ( 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 + mutexLikeMap map[string]*sync.Mutex + mutexHashMap map[string]*sync.Mutex + likePageMap map[string]map[string]struct{} + salt []byte } -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")) +var CCache *ClientCache +func NewClientCache(salt []byte) *ClientCache { return &ClientCache{ hashMap: make(map[string]string), - likePageMap: likePageMap, - viewPageMap: viewPageMap, - pageMutexMap: pageMutexMap, + mutexLikeMap: make(map[string]*sync.Mutex), + mutexHashMap: make(map[string]*sync.Mutex), + likePageMap: make(map[string]map[string]struct{}), 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 _, ok := c.mutexHashMap[id]; !ok { + c.mutexHashMap[id] = &sync.Mutex{} + } + c.mutexHashMap[id].Lock() + defer c.mutexHashMap[id].Unlock() if val, ok := c.hashMap[id]; ok { slog.Debug("gave a newly generated hash", slog.String("hash", val)) @@ -149,25 +50,7 @@ func (c *ClientCache) GetHash(id string) string { 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 } @@ -175,27 +58,15 @@ func (c *ClientCache) GetLikeStatus(id string, page string) bool { 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) +func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { + if _, ok := c.mutexLikeMap[id]; !ok { + c.mutexLikeMap[id] = &sync.Mutex{} } - return 0 -} + c.mutexLikeMap[id].Lock() + defer c.mutexLikeMap[id].Unlock() -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{}{} @@ -208,16 +79,16 @@ func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { } 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.mutexLikeMap[id]; !ok { + c.mutexLikeMap[id] = &sync.Mutex{} + } + c.mutexLikeMap[id].Lock() + defer c.mutexLikeMap[id].Unlock() if _, ok := c.likePageMap[page]; !ok { return true } + hash := c.GetHash(id) if _, ok := c.likePageMap[page][hash]; !ok { return true } @@ -225,94 +96,3 @@ func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) { delete(c.likePageMap[page], hash) return false } - -func (c *ClientCache) GetViewStatus(id string, page string) bool { - page = strings.Clone(page) - - mutex := c.getPageMutex(page) - mutex.RLock() - defer mutex.RUnlock() - - if _, ok := c.viewPageMap[page]; !ok { - return false - } - _, ok := c.viewPageMap[page][c.GetHash(id)] - return ok -} - -func (c *ClientCache) GetViewCount(page string) int { - page = strings.Clone(page) - - mutex := c.getPageMutex(page) - mutex.RLock() - defer mutex.RUnlock() - - if userSet, ok := c.viewPageMap[page]; ok { - return len(userSet) - } - return 0 -} - -func (c *ClientCache) View(id string, page string) { - page = strings.Clone(page) - hash := c.GetHash(id) - - mutex := c.getPageMutex(page) - mutex.Lock() - defer mutex.Unlock() - - if _, ok := c.viewPageMap[page]; !ok { - c.viewPageMap[page] = make(map[string]struct{}) - } - c.viewPageMap[page][hash] = struct{}{} -} - -func batchSave(tx *sql.Tx, table string, pageMap map[string]map[string]struct{}) (err error) { - if _, err = tx.Exec(fmt.Sprintf("delete from %s;", table)); err != nil { - return fmt.Errorf("fail to truncate table %s: %w", table, err) - } - - userIdBytes := make(map[string][]byte) - - sqlStatement := fmt.Sprintf(` - INSERT OR IGNORE INTO %s (page_ref, user_id) - VALUES %s(?, ?); - `, table, strings.Repeat("(?, ?), ", 99)) - sqlStatementVars := make([]any, 0, 200) - - for pageRef, userSet := range pageMap { - for userId := range userSet { - if _, ok := userIdBytes[userId]; !ok { - userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId) - if err != nil { - slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error())) - continue - } - } - - sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId])) - if len(sqlStatementVars) < 200 { - continue - } - - if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { - slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error())) - } - - sqlStatementVars = make([]any, 0, 200) - } - } - - if len(sqlStatementVars) > 0 { - sqlStatement = fmt.Sprintf(` - INSERT OR IGNORE INTO %s (page_ref, user_id) - VALUES %s(?, ?); - `, table, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1)) - - if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil { - slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error())) - } - } - - return nil -} diff --git a/internal/router/handlers/api-v1-blog-search.go b/internal/router/handlers/api-v1-blog-search.go deleted file mode 100644 index b59f09a..0000000 --- a/internal/router/handlers/api-v1-blog-search.go +++ /dev/null @@ -1,145 +0,0 @@ -package handlers - -import ( - "cmp" - "encoding/json" - "fmt" - "log/slog" - "net/url" - "regexp" - "slices" - "time" - - "github.com/SayaAndy/saya-today-web/internal/blog" - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type BlogSearchHandler struct { - router.BasicHandler - getTagsQuery *regexp.Regexp -} - -func init() { - router.Routes = append(router.Routes, &BlogSearchHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)}) -} - -func (r *BlogSearchHandler) Filter() (method string, path string) { - return "GET", "/api/v1/blog-search" -} - -func (r *BlogSearchHandler) IsTemplated() bool { - return false -} - -func (r *BlogSearchHandler) TemplatesToInject() []string { - return []string{"views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"} -} - -func (r *BlogSearchHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *BlogSearchHandler) ToValidateLang() router.LangSetting { - return router.InForm -} - -func (r *BlogSearchHandler) RateLimiter() *fiber.Handler { - return &router.RateLimiterLoose -} - -func (r *BlogSearchHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - sort := c.Query("sort") - tz := c.Query("tz") - medley := c.Query("medley") - highlight := c.Query("highlight") - hideTags := c.QueryBool("hideTags", false) - hidePublishedTime := c.QueryBool("hidePublishedTime", false) - - 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 []*blog.Page - if pagesBytes, ok := supplements.PageCache.Get(cacheKey); pagesBytes != nil || ok { - json.Unmarshal(pagesBytes, &pages) - } else { - pages, err = supplements.BlogClient.Scan(lang + "/") - if err != nil { - return fiber.StatusInternalServerError, fmt.Errorf("failed to scan pages for '%s' lang: %w", lang, err) - } - pagesBytes, _ := json.Marshal(pages) - supplements.PageCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), r.CacheDuration()) - } - - encodedQuery := c.Request().URI().QueryString() - decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) - matches := r.getTagsQuery.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) { - if medley == "" || medley == page.Metadata.Medley { - 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": supplements.ClientCache.GetLikeCount(page.FileName), - "Liked": supplements.ClientCache.GetLikeStatus(c.IP(), page.FileName), - "ViewCount": supplements.ClientCache.GetViewCount(page.FileName), - "Viewed": supplements.ClientCache.GetViewStatus(c.IP(), page.FileName), - "Medley": page.Metadata.Medley, - "MedleyPart": page.Metadata.MedleyPart, - "ToHighlight": page.FileName == highlight, - }) - break - } - } - } - } - - slices.SortFunc(pageMeta, func(a, b fiber.Map) int { - switch sort { - case "titleAsc": - return cmp.Compare(a["Title"].(string), b["Title"].(string)) - case "titleDesc": - return cmp.Compare(b["Title"].(string), a["Title"].(string)) - case "actionDateAsc": - return cmp.Compare(a["ActionDate"].(string), b["ActionDate"].(string)) - case "actionDateDesc": - return cmp.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) - case "medley": - return cmp.Compare(a["MedleyPart"].(int), b["MedleyPart"].(int)) - } - return 0 - }) - - templateMap["BlogPages"] = pageMeta - templateMap["HideTags"] = hideTags - templateMap["HidePublishedTime"] = hidePublishedTime - - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/api-v1-email-is-in-verification.go b/internal/router/handlers/api-v1-email-is-in-verification.go deleted file mode 100644 index b8ae162..0000000 --- a/internal/router/handlers/api-v1-email-is-in-verification.go +++ /dev/null @@ -1,60 +0,0 @@ -package handlers - -import ( - "fmt" - "html/template" - "strings" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/gofiber/fiber/v2" -) - -type OngoingVerificationHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &OngoingVerificationHandler{}) -} - -func (r *OngoingVerificationHandler) Filter() (method string, path string) { - return "GET", "/api/v1/email/is-in-verification" -} - -func (r *OngoingVerificationHandler) IsTemplated() bool { - return false -} - -func (r *OngoingVerificationHandler) TemplatesToInject() []string { - return []string{"views/partials/personal-page-status.html"} -} - -func (r *OngoingVerificationHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *OngoingVerificationHandler) ToValidateLang() router.LangSetting { - return router.InReferer -} - -func (r *OngoingVerificationHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - isAllowed, whenAllowed, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(c.IP()) - - sterileDataset := make(map[string]any) - sterileDataset["code-expiry-time"] = template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli())) - - templateMap["StatusId"] = "email-message" - templateMap["DataAttributes"] = sterileDataset - - if !isAllowed { - sterileDataset["striked-end-time"] = template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli())) - templateMap["Status"] = "Neutral" - templateMap["Message"] = strings.ReplaceAll(l10n.T.GetPath(lang, "UserProfile", "DelayTilVerification").(string), "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")) - } else { - templateMap["Status"] = "OK" - templateMap["Message"] = "" - } - - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/api-v1-email-send-verification-code.go b/internal/router/handlers/api-v1-email-send-verification-code.go deleted file mode 100644 index ea8df4d..0000000 --- a/internal/router/handlers/api-v1-email-send-verification-code.go +++ /dev/null @@ -1,102 +0,0 @@ -package handlers - -import ( - "fmt" - "html/template" - "log/slog" - "strings" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/gofiber/fiber/v2" -) - -type SendVerificationCodeHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &SendVerificationCodeHandler{}) -} - -func (r *SendVerificationCodeHandler) Filter() (method string, path string) { - return "POST", "/api/v1/email/send-verification-code" -} - -func (r *SendVerificationCodeHandler) IsTemplated() bool { - return false -} - -func (r *SendVerificationCodeHandler) TemplatesToInject() []string { - return []string{"views/partials/personal-page-status.html"} -} - -func (r *SendVerificationCodeHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *SendVerificationCodeHandler) ToValidateLang() router.LangSetting { - return router.InReferer -} - -func (r *SendVerificationCodeHandler) RateLimiter() *fiber.Handler { - return &router.RateLimiterStrict -} - -func (r *SendVerificationCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - id := c.IP() - templateMap["StatusId"] = "email-message" - - email := c.FormValue("email") - if email == "" { - templateMap["Status"] = "Failed" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "EmailEmpty").(string) - return fiber.StatusUnprocessableEntity, nil - } - - isTaken, err := supplements.Mailer.MailIsTaken(email) - if err != nil { - templateMap["Status"] = "Failed" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationCodeSendingError").(string) - slog.Error("failed to check if address is already taken", slog.String("error", err.Error())) - return fiber.StatusUnprocessableEntity, nil - } - - if isTaken { - templateMap["Status"] = "Failed" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "EmailTaken").(string) - return fiber.StatusUnprocessableEntity, nil - } - - if isAllowed, whenAllowed, _ := supplements.Mailer.IsAllowedToRetryVerification(id); !isAllowed { - templateMap["Status"] = "Failed" - templateMap["Message"] = strings.ReplaceAll(l10n.T.GetPath(lang, "UserProfile", "DelayTilVerification").(string), "{}", whenAllowed.Format("2006-01-02 15:04:05 MST")) - templateMap["DataAttributes"] = map[string]any{ - "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", whenAllowed.UnixMilli())), - } - return fiber.StatusUnprocessableEntity, nil - } - - if previousEmail, _, _ := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(id)); previousEmail == email { - templateMap["Status"] = "Failed" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "EmailAlreadyValidated").(string) - return fiber.StatusUnprocessableEntity, nil - } - - if err = supplements.Mailer.SendVerificationCode(id, email, lang); err != nil { - templateMap["Status"] = "Failed" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationCodeSendingError").(string) - slog.Error("failed to send a verification code", slog.String("error", err.Error())) - return fiber.StatusUnprocessableEntity, nil - } - - _, endTime, codeExpiry := supplements.Mailer.IsAllowedToRetryVerification(id) - templateMap["Status"] = "OK" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationCodeSent").(string) - templateMap["DataAttributes"] = map[string]any{ - "striked-end-time": template.HTMLAttr(fmt.Sprintf("data-striked-end-time=\"%d\"", endTime.UnixMilli())), - "code-expiry-time": template.HTMLAttr(fmt.Sprintf("data-code-expiry-time=\"%d\"", codeExpiry.UnixMilli())), - } - - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/api-v1-email-verify.go b/internal/router/handlers/api-v1-email-verify.go deleted file mode 100644 index 65ad6d9..0000000 --- a/internal/router/handlers/api-v1-email-verify.go +++ /dev/null @@ -1,67 +0,0 @@ -package handlers - -import ( - "html/template" - "log/slog" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/gofiber/fiber/v2" -) - -type VerifyCodeHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &VerifyCodeHandler{}) -} - -func (r *VerifyCodeHandler) Filter() (method string, path string) { - return "POST", "/api/v1/email/verify" -} - -func (r *VerifyCodeHandler) IsTemplated() bool { - return false -} - -func (r *VerifyCodeHandler) TemplatesToInject() []string { - return []string{"views/partials/personal-page-status.html"} -} - -func (r *VerifyCodeHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *VerifyCodeHandler) ToValidateLang() router.LangSetting { - return router.InReferer -} - -func (r *VerifyCodeHandler) RateLimiter() *fiber.Handler { - return &router.RateLimiterStrict -} - -func (r *VerifyCodeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - verificationCode := c.FormValue("email_code") - templateMap["StatusId"] = "verification-message" - - if verificationCode == "" { - templateMap["Status"] = "Failed" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationEmpty").(string) - return fiber.StatusUnprocessableEntity, nil - } - - if err = supplements.Mailer.Verify(verificationCode, lang); err != nil { - slog.Warn("verification code is invalid", slog.String("verification_code", verificationCode), slog.String("error", err.Error())) - templateMap["Status"] = "Failed" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationFailed").(string) - return fiber.StatusUnprocessableEntity, nil - } - - templateMap["Status"] = "OK" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "VerificationSuccess").(string) - templateMap["DataAttributes"] = map[string]any{ - "hide-verification-panel": template.HTMLAttr("data-code-expiry-time=\"true\""), - } - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/api-v1-like-get.go b/internal/router/handlers/api-v1-like-get.go deleted file mode 100644 index 9e2c856..0000000 --- a/internal/router/handlers/api-v1-like-get.go +++ /dev/null @@ -1,63 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type GetLikeHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &GetLikeHandler{}) -} - -func (r *GetLikeHandler) Filter() (method string, path string) { - return "GET", "/api/v1/like" -} - -func (r *GetLikeHandler) IsTemplated() bool { - return false -} - -func (r *GetLikeHandler) TemplatesToInject() []string { - return []string{"views/partials/blog-page-like-button.html"} -} - -func (r *GetLikeHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *GetLikeHandler) ToValidateLang() router.LangSetting { - return router.InReferer -} - -func (r *GetLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - path, pathParts, _, err := router.GetPathFromReferer(c) - if err != nil { - return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err) - } - - if len(pathParts) != 3 || pathParts[1] != "blog" { - return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path) - } - - page := pathParts[2] - pageLink := lang + "/" + page + ".md" - if pages, _ := supplements.BlogClient.Scan(pageLink); len(pages) == 0 { - return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink) - } - - ip := c.IP() - likeStatus := supplements.ClientCache.GetLikeStatus(ip, page) - - slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus)) - templateMap["Liked"] = likeStatus - templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page) - - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/api-v1-like-put.go b/internal/router/handlers/api-v1-like-put.go deleted file mode 100644 index 8305d2b..0000000 --- a/internal/router/handlers/api-v1-like-put.go +++ /dev/null @@ -1,78 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - "strconv" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type PutLikeHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &PutLikeHandler{}) -} - -func (r *PutLikeHandler) Filter() (method string, path string) { - return "PUT", "/api/v1/like" -} - -func (r *PutLikeHandler) IsTemplated() bool { - return false -} - -func (r *PutLikeHandler) TemplatesToInject() []string { - return []string{"views/partials/blog-page-like-button.html"} -} - -func (r *PutLikeHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *PutLikeHandler) ToValidateLang() router.LangSetting { - return router.InReferer -} - -func (r *PutLikeHandler) RateLimiter() *fiber.Handler { - return &router.RateLimiterMedium -} - -func (r *PutLikeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - path, pathParts, _, err := router.GetPathFromReferer(c) - if err != nil { - return fiber.StatusBadRequest, fmt.Errorf("error getting path from referer: %w", err) - } - - if len(pathParts) != 3 && pathParts[1] != "blog" { - return fiber.StatusBadRequest, fmt.Errorf("invalid path format: expected '/:lang/blog/:page', got '%s'", path) - } - - page := pathParts[2] - pageLink := lang + "/" + page + ".md" - if pages, _ := supplements.BlogClient.Scan(pageLink); len(pages) == 0 { - return fiber.StatusNotFound, fmt.Errorf("server did not find '%s' article", pageLink) - } - - newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true")) - if err != nil { - return fiber.StatusBadRequest, fmt.Errorf("invalid like value '%s'", c.FormValue("like")) - } - - ip := c.IP() - if newLikeStatus { - supplements.ClientCache.LikeOn(ip, page) - } else { - supplements.ClientCache.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))) - templateMap["Liked"] = newLikeStatus - templateMap["LikedCount"] = supplements.ClientCache.GetLikeCount(page) - templateMap["StatusId"] = "email-message" - - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/api-v1-map-get.go b/internal/router/handlers/api-v1-map-get.go deleted file mode 100644 index e19c54a..0000000 --- a/internal/router/handlers/api-v1-map-get.go +++ /dev/null @@ -1,116 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - "slices" - "strconv" - "strings" - - "github.com/SayaAndy/saya-today-web/internal/blog" - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type GetMapHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &GetMapHandler{}) -} - -func (r *GetMapHandler) Filter() (method string, path string) { - return "GET", "/api/v1/map" -} - -func (r *GetMapHandler) IsTemplated() bool { - return false -} - -func (r *GetMapHandler) TemplatesToInject() []string { - return []string{"views/partials/global-map-widget.html"} -} - -func (r *GetMapHandler) ToCache() router.CacheSetting { - return router.ByUrlAndQuery -} - -func (r *GetMapHandler) ToValidateLang() router.LangSetting { - return router.InReferer -} - -func (r *GetMapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - codename := c.Query("codename") - zoom := c.QueryInt("zoom", 4) - zoomPosition := c.Query("zoomPosition") - - pages, err := supplements.BlogClient.Scan(lang + "/") - status := fiber.StatusOK - if err != nil { - slog.Error("received an error while scanning blog pages", - slog.String("error", err.Error()), - slog.String("lang", lang), - ) - status = fiber.StatusPartialContent - pages = []*blog.Page{} - } - slices.SortFunc(pages, func(a *blog.Page, b *blog.Page) int { - return a.Metadata.PublishedTime.Compare(b.Metadata.PublishedTime) - }) - - type MapMarker struct { - Index int `json:"Index"` - Title string `json:"Title"` - PageLink string `json:"PageLink"` - Lat float64 `json:"Lat"` - Long float64 `json:"Long"` - AccuracyMeters int64 `json:"AccuracyMeters"` - Thumbnail string `json:"Thumbnail"` - ToHighlight bool `json:"ToHighlight"` - } - - templateMap["MapLocationLat"] = 45.4507 - templateMap["MapLocationLong"] = 68.8319 - templateMap["MapLocationZoom"] = zoom - templateMap["ZoomPosition"] = zoomPosition - - mapMarkers := make([]*MapMarker, 0, len(pages)) - for i, 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) - } - - toHighlight := page.FileName == codename - if toHighlight { - templateMap["MapLocationLat"] = x - templateMap["MapLocationLong"] = y - } - - mapMarkers = append(mapMarkers, &MapMarker{ - Index: i, - Title: page.Metadata.Title, - PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName), - Lat: x, - Long: y, - AccuracyMeters: areaError, - Thumbnail: page.Metadata.Thumbnail, - ToHighlight: toHighlight, - }) - } - - templateMap["MapMarkers"] = mapMarkers - - return status, nil -} diff --git a/internal/router/handlers/api-v1-subs-put.go b/internal/router/handlers/api-v1-subs-put.go deleted file mode 100644 index 12dc5cf..0000000 --- a/internal/router/handlers/api-v1-subs-put.go +++ /dev/null @@ -1,70 +0,0 @@ -package handlers - -import ( - "github.com/SayaAndy/saya-today-web/internal/mailer" - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/gofiber/fiber/v2" -) - -type PutSubsHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &PutSubsHandler{}) -} - -func (r *PutSubsHandler) Filter() (method string, path string) { - return "PUT", "/api/v1/subs" -} - -func (r *PutSubsHandler) IsTemplated() bool { - return false -} - -func (r *PutSubsHandler) TemplatesToInject() []string { - return []string{"views/partials/personal-page-status.html"} -} - -func (r *PutSubsHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *PutSubsHandler) ToValidateLang() router.LangSetting { - return router.InReferer -} - -func (r *PutSubsHandler) RateLimiter() *fiber.Handler { - return &router.RateLimiterMedium -} - -func (r *PutSubsHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - templateMap["StatusId"] = "subs-message" - - 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: - templateMap["Status"] = "Failed" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "SubscribeInvalidType").(string) - return fiber.StatusUnprocessableEntity, nil - } - - specificTags := c.FormValue("tags_picked") - if err = supplements.Mailer.Subscribe(supplements.Mailer.GetHash(c.IP()), subscriptionTypeEnum, specificTags); err != nil { - templateMap["Status"] = "Failed" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "FailedToSubscribe").(string) - return fiber.StatusUnprocessableEntity, nil - } - - templateMap["Status"] = "OK" - templateMap["Message"] = l10n.T.GetPath(lang, "UserProfile", "SubscribedSuccessfully").(string) - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/api-v1-tz-get.go b/internal/router/handlers/api-v1-tz-get.go deleted file mode 100644 index 8b08113..0000000 --- a/internal/router/handlers/api-v1-tz-get.go +++ /dev/null @@ -1,65 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - "time" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type GetTimezoneHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &GetTimezoneHandler{}) -} - -func (r *GetTimezoneHandler) Filter() (method string, path string) { - return "GET", "/api/v1/tz" -} - -func (r *GetTimezoneHandler) IsTemplated() bool { - return false -} - -func (r *GetTimezoneHandler) TemplatesToInject() []string { - return []string{} -} - -func (r *GetTimezoneHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *GetTimezoneHandler) ToValidateLang() router.LangSetting { - return router.NotRequired -} - -func (r *GetTimezoneHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err 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 - } - } - - if err != nil { - return fiber.StatusBadRequest, fmt.Errorf("invalid timestamp format for '%s'", timestampString) - } - - templateMap["Output"] = []byte(timestamp.In(loc).Format("2006-01-02 15:04:05 -07:00")) - - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/lang-blog-title.go b/internal/router/handlers/lang-blog-title.go deleted file mode 100644 index 76dc335..0000000 --- a/internal/router/handlers/lang-blog-title.go +++ /dev/null @@ -1,182 +0,0 @@ -package handlers - -import ( - "bytes" - "fmt" - "html/template" - "strings" - "time" - - "github.com/SayaAndy/saya-today-web/internal/blog" - "github.com/SayaAndy/saya-today-web/internal/frontmatter" - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/gofiber/fiber/v2" - "github.com/yuin/goldmark" -) - -func init() { - router.Routes = append(router.Routes, &BlogPageHandler{}) -} - -type BlogPageHandler struct { - router.BasicHandler -} - -func (r *BlogPageHandler) Filter() (method string, path string) { - return "GET", "/:lang/blog/:title" -} - -func (r *BlogPageHandler) IsTemplated() bool { - return true -} - -func (r *BlogPageHandler) TemplatesToInject() []string { - return []string{"views/pages/blog-page.html"} -} - -func (r *BlogPageHandler) ToCache() router.CacheSetting { - return router.ByUrlOnly -} - -func (r *BlogPageHandler) CacheDuration() time.Duration { - return 15 * time.Minute -} - -func (r *BlogPageHandler) ToValidateLang() router.LangSetting { - return router.InPath -} - -func (r *BlogPageHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo { - sitemapInfo := []router.SitemapInfo{} - - pages, err := supplements.BlogClient.Scan("") - if err != nil { - return sitemapInfo - } - - for _, page := range pages { - sitemapInfo = append(sitemapInfo, router.SitemapInfo{ - Loc: "/" + page.Lang + "/blog/" + page.FileName, - LastModified: page.ModifiedTime, - Priority: 1.0, - }) - } - - return sitemapInfo -} - -func (r *BlogPageHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { - metadata, _, err := supplements.BlogClient.ReadFrontmatter(lang + "/" + c.Params("title") + ".md") - if err != nil { - return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err) - } - title := metadata.Title - if metadata.Medley != "" { - title += " // " + l10n.T.GetPath(lang, "Medleys", metadata.Medley).(string) - } - - return []router.MetaField{ - {Property: "og:title", Content: title}, - {Property: "og:description", Content: fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate)}, - {Property: "og:image", Content: fmt.Sprintf(supplements.PhotoStorage.Thumbnail560p.BaseUrl, metadata.Thumbnail)}, - {Property: "og:url", Content: fmt.Sprintf("%s/%s/blog/%s", templateMap["CanonicalEndpoint"], lang, c.Params("title"))}, - {Property: "og:type", Content: "website"}, - {Name: "twitter:card", Content: "summary_large_image"}, - }, nil -} - -func (r *BlogPageHandler) AddLinkedData(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (ld map[string]any, err error) { - metadata, _, err := supplements.BlogClient.ReadFrontmatter(lang + "/" + c.Params("title") + ".md") - if err != nil { - return nil, fmt.Errorf("failed to read frontmatter of the desired blog post: %w", err) - } - title := metadata.Title - if metadata.Medley != "" { - title += " // " + l10n.T.GetPath(lang, "Medleys", metadata.Medley).(string) - } - - return map[string]any{ - "@context": "https://schema.org", - "@type": "Article", - "headline": title, - "description": fmt.Sprintf("%s [%s]", metadata.ShortDescription, metadata.ActionDate), - "author": map[string]string{"@type": "Person", "name": "Saya Andy"}, - "datePublished": metadata.PublishedTime.UTC().Format(time.RFC3339), - }, nil -} - -func (r *BlogPageHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - _, pathParts, _, err := router.GetPathFromReferer(c) - if err != nil { - return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err) - } - title := pathParts[2] - - metadata, parsedMarkdown, err := readBlogPost(supplements.MarkdownRenderer, supplements.BlogClient, lang+"/"+title) - if err != nil { - return fiber.StatusNotFound, fmt.Errorf("failed to find '%s' post: %w", title, err) - } - - 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] - } - - templateMap["MapLocationX"] = x - templateMap["MapLocationY"] = y - templateMap["MapLocationAreaMeters"] = areaError - templateMap["Title"] = metadata.Title - templateMap["Codename"] = title - templateMap["ParsedMarkdown"] = template.HTML(parsedMarkdown) - templateMap["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00") - templateMap["ActionDate"] = metadata.ActionDate - templateMap["ShortDescription"] = metadata.ShortDescription - templateMap["Thumbnail"] = metadata.Thumbnail - templateMap["Medley"] = metadata.Medley - - go supplements.ClientCache.View(c.IP(), title) - - return fiber.StatusOK, nil -} - -func (r *BlogPageHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - path, pathParts, _, err := router.GetPathFromReferer(c) - if err != nil { - return fiber.StatusBadRequest, fmt.Errorf("failed to get path from referer: %w", err) - } - - metadata, _, err := supplements.BlogClient.ReadFrontmatter(lang + "/" + pathParts[2] + ".md") - if err != nil { - return fiber.StatusNotFound, fmt.Errorf("could not read '%s' for metadata: %w", path, err) - } - - pageTitle := metadata.Title - if metadata.Medley != "" { - pageTitle += " // " + l10n.T.GetPath(lang, "Medleys", metadata.Medley).(string) - } - - templateMap["Title"] = metadata.Title - templateMap["PageTitle"] = pageTitle - - return fiber.StatusOK, nil -} - -func readBlogPost(md goldmark.Markdown, blogClient blog.Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) { - metadata, markdown, err := blogClient.ReadFrontmatter(sourceName + ".md") - if err != nil { - return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err) - } - - var buf bytes.Buffer - if err := md.Convert(markdown, &buf); err != nil { - return nil, "", fmt.Errorf("convert source context from md to html: %w", err) - } - - return metadata, buf.String(), nil -} diff --git a/internal/router/handlers/lang-blog.go b/internal/router/handlers/lang-blog.go deleted file mode 100644 index c31c2eb..0000000 --- a/internal/router/handlers/lang-blog.go +++ /dev/null @@ -1,129 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - "net/url" - "regexp" - "slices" - "strings" - "time" - - "github.com/SayaAndy/saya-today-web/internal/blog" - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/gofiber/fiber/v2" -) - -func init() { - router.Routes = append(router.Routes, &CatalogueHandler{getTagsQuery: regexp.MustCompile(`tags\[\]=([\w]+)`)}) -} - -type CatalogueHandler struct { - router.BasicHandler - getTagsQuery *regexp.Regexp -} - -func (r *CatalogueHandler) Filter() (method string, path string) { - return "GET", "/:lang/blog" -} - -func (r *CatalogueHandler) IsTemplated() bool { - return true -} - -func (r *CatalogueHandler) TemplatesToInject() []string { - return []string{"views/pages/blog-catalogue.html"} -} - -func (r *CatalogueHandler) ToCache() router.CacheSetting { - return router.ByUrlAndQuery -} - -func (r *CatalogueHandler) CacheDuration() time.Duration { - return 5 * time.Minute -} - -func (r *CatalogueHandler) ToValidateLang() router.LangSetting { - return router.InPath -} - -func (r *CatalogueHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo { - sitemapInfo := []router.SitemapInfo{} - for _, lang := range supplements.AvailableLanguages { - sitemapInfo = append(sitemapInfo, router.SitemapInfo{Loc: "/" + lang.Name + "/blog", Priority: 0.3}) - } - return sitemapInfo -} - -func (r *CatalogueHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { - return []router.MetaField{ - {Property: "og:title", Content: l10n.T.GetPath(lang, "BlogSearch", "Header").(string)}, - {Property: "og:description", Content: l10n.T.GetPath(lang, "BlogSearch", "Description").(string)}, - {Property: "og:url", Content: fmt.Sprintf("%s/%s/blog", templateMap["CanonicalEndpoint"], lang)}, - {Property: "og:type", Content: "website"}, - }, nil -} - -func (r *CatalogueHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - querySort := c.Query("sort", "publicationDateDesc") - previousBlogPage := c.Query("codename") - - encodedQuery := c.Request().URI().QueryString() - decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) - matches := r.getTagsQuery.FindAllStringSubmatch(decodedQuery, -1) - - queryTags := make([]string, 0, len(matches)) - for _, match := range matches { - queryTags = append(queryTags, string(match[1])) - } - - tagsArray, err := getTags(supplements.BlogClient, lang) - if err != nil { - return fiber.StatusInternalServerError, fmt.Errorf("failed to get the available tags") - } - - templateMap["Tags"] = tagsArray - templateMap["QuerySort"] = querySort - templateMap["QueryTags"] = strings.Join(queryTags, ",") - templateMap["Title"] = l10n.T.GetPath(lang, "BlogSearch", "Header").(string) - templateMap["PreviousBlogPage"] = previousBlogPage - - return fiber.StatusOK, nil -} - -func (r *CatalogueHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - templateMap["Title"] = l10n.T.GetPath(lang, "BlogSearch", "Header").(string) - return fiber.StatusOK, nil -} - -type Tag struct { - Name string `json:"Name" yaml:"name"` - Count int `json:"Count" yaml:"count"` -} - -func getTags(blogClient blog.Client, lang string) (tags []Tag, err error) { - pages, err := blogClient.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/handlers/lang-map.go b/internal/router/handlers/lang-map.go deleted file mode 100644 index 32ab35e..0000000 --- a/internal/router/handlers/lang-map.go +++ /dev/null @@ -1,46 +0,0 @@ -package handlers - -import ( - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type MapHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &MapHandler{}) -} - -func (r *MapHandler) Filter() (method string, path string) { - return "GET", "/:lang/map" -} - -func (r *MapHandler) IsTemplated() bool { - return false -} - -func (r *MapHandler) TemplatesToInject() []string { - return []string{"views/pages/global-map.html"} -} - -func (r *MapHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *MapHandler) ToValidateLang() router.LangSetting { - return router.InPath -} - -func (r *MapHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo { - sitemapInfo := []router.SitemapInfo{} - for _, lang := range supplements.AvailableLanguages { - sitemapInfo = append(sitemapInfo, router.SitemapInfo{Loc: "/" + lang.Name + "/map", Priority: 0.3}) - } - return sitemapInfo -} - -func (r *MapHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/lang-user-unsubscribe.go b/internal/router/handlers/lang-user-unsubscribe.go deleted file mode 100644 index e22d854..0000000 --- a/internal/router/handlers/lang-user-unsubscribe.go +++ /dev/null @@ -1,69 +0,0 @@ -package handlers - -import ( - "log/slog" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/gofiber/fiber/v2" -) - -type UnsubscribeHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &UnsubscribeHandler{}) -} - -func (r *UnsubscribeHandler) Filter() (method string, path string) { - return "GET", "/:lang/user/unsubscribe" -} - -func (r *UnsubscribeHandler) IsTemplated() bool { - return false -} - -func (r *UnsubscribeHandler) TemplatesToInject() []string { - return []string{"views/pages/unsubscribe-page.html"} -} - -func (r *UnsubscribeHandler) ToValidateLang() router.LangSetting { - return router.InPath -} - -func (r *UnsubscribeHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - var statusEmoji, statusText, statusColor string - var status int - - unsubscribeCode := c.FormValue("code") - if unsubscribeCode == "" { - statusColor = "0, 0, 255" - statusEmoji = "(╭ರ_•́)" - statusText = l10n.T.GetPath(lang, "UnsubscribePage", "UnsetCode").(string) - status = fiber.ErrBadRequest.Code - } else if clientError, serverError := supplements.Mailer.Unsubscribe(unsubscribeCode); clientError != nil { - slog.Info("got a client error when unsubscribing", slog.String("error", clientError.Error())) - statusColor = "255, 0, 0" - statusEmoji = "(͠≖~≖ ͡ )" - statusText = l10n.T.GetPath(lang, "UnsubscribePage", "InvalidCode").(string) - 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 = l10n.T.GetPath(lang, "UnsubscribePage", "OnServerError").(string) - status = fiber.ErrInternalServerError.Code - } else { - statusColor = "0, 255, 0" - statusEmoji = "♡⸜(˶˃ ᵕ ˂˶)⸝♡" - statusText = l10n.T.GetPath(lang, "UnsubscribePage", "Success").(string) - status = fiber.StatusOK - } - - templateMap["StatusColor"] = statusColor - templateMap["StatusEmoji"] = statusEmoji - templateMap["StatusText"] = statusText - - return status, nil -} diff --git a/internal/router/handlers/lang-user.go b/internal/router/handlers/lang-user.go deleted file mode 100644 index 085a9aa..0000000 --- a/internal/router/handlers/lang-user.go +++ /dev/null @@ -1,89 +0,0 @@ -package handlers - -import ( - "fmt" - "log/slog" - - "github.com/SayaAndy/saya-today-web/internal/mailer" - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/gofiber/fiber/v2" -) - -func init() { - router.Routes = append(router.Routes, &UserHandler{}) -} - -type UserHandler struct { - router.BasicHandler -} - -func (r *UserHandler) Filter() (method string, path string) { - return "GET", "/:lang/user" -} - -func (r *UserHandler) IsTemplated() bool { - return true -} - -func (r *UserHandler) TemplatesToInject() []string { - return []string{"views/pages/user-page.html"} -} - -func (r *UserHandler) ToCache() router.CacheSetting { - return router.Disabled -} - -func (r *UserHandler) ToValidateLang() router.LangSetting { - return router.InPath -} - -func (r *UserHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { - return []router.MetaField{ - {Name: "robots", Content: "noindex,nofollow"}, - {Property: "og:title", Content: l10n.T.GetPath(lang, "UserProfile", "Header").(string)}, - {Property: "og:description", Content: l10n.T.GetPath(lang, "UserProfile", "Description").(string)}, - {Property: "og:url", Content: fmt.Sprintf("%s/%s/user", templateMap["CanonicalEndpoint"], lang)}, - {Property: "og:type", Content: "website"}, - }, nil -} - -func (r *UserHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - templateMap["Title"] = l10n.T.GetPath(lang, "UserProfile", "Header").(string) - - email, _, err := supplements.Mailer.GetInfo(supplements.Mailer.GetHash(c.IP())) - if err != nil { - slog.Error("get info from mailer about a client", slog.String("error", err.Error())) - } - - tagsArray, err := getTags(supplements.BlogClient, lang) - if err != nil { - return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the available tags") - } - - subscriptionType, tags, err := supplements.Mailer.GetSubscriptions(c.IP()) - if err != nil { - return fiber.ErrInternalServerError.Code, fmt.Errorf("failed to get the user subscriptions") - } - - switch subscriptionType { - case mailer.None: - templateMap["TagsPicked"] = "none" - case mailer.All: - templateMap["TagsPicked"] = "all" - case mailer.Specific: - templateMap["TagsPicked"] = "specific" - } - - templateMap["TagsPickedList"] = tags - - templateMap["Email"] = email - templateMap["EmailCode"] = c.Query("email_code") - templateMap["ExistingTags"] = tagsArray - return fiber.StatusOK, nil -} - -func (r *UserHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - templateMap["Title"] = l10n.T.GetPath(lang, "UserProfile", "Header").(string) - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/lang.go b/internal/router/handlers/lang.go deleted file mode 100644 index cc0b33b..0000000 --- a/internal/router/handlers/lang.go +++ /dev/null @@ -1,81 +0,0 @@ -package handlers - -import ( - "fmt" - "math/rand/v2" - "time" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/SayaAndy/saya-today-web/l10n" - "github.com/gofiber/fiber/v2" -) - -func init() { - router.Routes = append(router.Routes, &HomeHandler{}) -} - -type HomeHandler struct { - router.BasicHandler -} - -func (r *HomeHandler) Filter() (method string, path string) { - return "GET", "/:lang<len(2)>" -} - -func (r *HomeHandler) IsTemplated() bool { - return true -} - -func (r *HomeHandler) TemplatesToInject() []string { - return []string{"views/pages/home-page.html"} -} - -func (r *HomeHandler) ToCache() router.CacheSetting { - return router.ByUrlOnly -} - -func (r *HomeHandler) CacheDuration() time.Duration { - return 10 * time.Minute -} - -func (r *HomeHandler) ToValidateLang() router.LangSetting { - return router.InPath -} - -func (r *HomeHandler) SitemapInfo(supplements *router.Supplements) []router.SitemapInfo { - sitemapInfo := []router.SitemapInfo{} - for _, lang := range supplements.AvailableLanguages { - sitemapInfo = append(sitemapInfo, router.SitemapInfo{Loc: "/" + lang.Name, Priority: 0.7}) - } - return sitemapInfo -} - -func (r *HomeHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { - return []router.MetaField{ - {Property: "og:title", Content: l10n.T.GetPath(lang, "HomePage", "Header").(string)}, - {Property: "og:description", Content: l10n.T.GetPath(lang, "HomePage", "HomePageDescription").(string)}, - {Property: "og:image", Content: fmt.Sprintf( - supplements.PhotoStorage.HomePageGifs.BaseUrl, - supplements.PhotoStorage.HomePageGifs.Indexes[rand.Int()%len(supplements.PhotoStorage.HomePageGifs.Indexes)], - )}, - {Property: "og:url", Content: fmt.Sprintf("%s/%s", templateMap["CanonicalEndpoint"], lang)}, - {Property: "og:type", Content: "website"}, - }, nil -} - -func (r *HomeHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - templateMap["Title"] = l10n.T.GetPath(lang, "HomePage", "Header").(string) - templateMap["FilledHeartCount"] = uint(40) - templateMap["OutlineHeartCount"] = uint(40) - templateMap["GifUrl"] = fmt.Sprintf( - supplements.PhotoStorage.HomePageGifs.BaseUrl, - supplements.PhotoStorage.HomePageGifs.Indexes[rand.Int()%len(supplements.PhotoStorage.HomePageGifs.Indexes)], - ) - templateMap["FunFacts"] = supplements.FactGiver.Give(lang) - return fiber.StatusOK, nil -} - -func (r *HomeHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - templateMap["Title"] = l10n.T.GetPath(lang, "HomePage", "Header").(string) - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/robots.txt.go b/internal/router/handlers/robots.txt.go deleted file mode 100644 index fc60af4..0000000 --- a/internal/router/handlers/robots.txt.go +++ /dev/null @@ -1,48 +0,0 @@ -package handlers - -import ( - "time" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type RobotsTxtHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &RobotsTxtHandler{}) -} - -func (r *RobotsTxtHandler) Filter() (method string, path string) { - return "GET", "/robots.txt" -} - -func (r *RobotsTxtHandler) IsTemplated() bool { - return false -} - -func (r *RobotsTxtHandler) TemplatesToInject() []string { - return []string{"views/pages/robots.txt"} -} - -func (r *RobotsTxtHandler) ToCache() router.CacheSetting { - return router.ByUrlOnly -} - -func (r *RobotsTxtHandler) CacheDuration() time.Duration { - return time.Hour -} - -func (r *RobotsTxtHandler) ToValidateLang() router.LangSetting { - return router.NotRequired -} - -func (r *RobotsTxtHandler) ContentType() string { - return fiber.MIMETextPlainCharsetUTF8 -} - -func (r *RobotsTxtHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/root.go b/internal/router/handlers/root.go deleted file mode 100644 index 4fc4e31..0000000 --- a/internal/router/handlers/root.go +++ /dev/null @@ -1,65 +0,0 @@ -package handlers - -import ( - "fmt" - "time" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -func init() { - router.Routes = append(router.Routes, &RootHandler{}) -} - -type RootHandler struct { - router.BasicHandler -} - -func (r *RootHandler) Filter() (method string, path string) { - return "GET", "/" -} - -func (r *RootHandler) IsTemplated() bool { - return true -} - -func (r *RootHandler) TemplatesToInject() []string { - return []string{"views/pages/language-pick.html"} -} - -func (r *RootHandler) ToCache() router.CacheSetting { - return router.ByUrlOnly -} - -func (r *RootHandler) CacheDuration() time.Duration { - return 24 * time.Hour -} - -func (r *RootHandler) SitemapInfo(_ *router.Supplements) []router.SitemapInfo { - return []router.SitemapInfo{{Loc: "/", Priority: 0.7}} -} - -func (r *RootHandler) AddMeta(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (meta []router.MetaField, err error) { - meta = []router.MetaField{ - {Property: "og:title", Content: "Saya Blog"}, - {Property: "og:description", Content: "The blog of a 25 years old, travelling God knows where, proud to be not mainstream // Личный блог 25-летки, странствующего по ебеням, зато не мейнстрим"}, - {Property: "og:url", Content: fmt.Sprint(templateMap["CanonicalEndpoint"]) + "/"}, - {Property: "og:type", Content: "website"}, - } - for _, field := range supplements.Meta { - meta = append(meta, router.MetaField{Name: field.Name, Content: field.Value}) - } - return meta, nil -} - -func (r *RootHandler) RenderBody(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) { - templateMap["Title"] = "Choose Your Language" - templateMap["AvailableLanguages"] = supplements.AvailableLanguages - return fiber.StatusOK, nil -} - -func (r *RootHandler) RenderHeader(c *fiber.Ctx, supplements *router.Supplements, _ string, templateMap fiber.Map) (statusCode int, err error) { - templateMap["Title"] = "Choose Your Language" - return fiber.StatusOK, nil -} diff --git a/internal/router/handlers/sitemap.xml.go b/internal/router/handlers/sitemap.xml.go deleted file mode 100644 index bb1df93..0000000 --- a/internal/router/handlers/sitemap.xml.go +++ /dev/null @@ -1,79 +0,0 @@ -package handlers - -import ( - "log/slog" - "time" - - "github.com/SayaAndy/saya-today-web/internal/router" - "github.com/gofiber/fiber/v2" -) - -type SitemapXmlHandler struct { - router.BasicHandler -} - -func init() { - router.Routes = append(router.Routes, &SitemapXmlHandler{}) -} - -func (r *SitemapXmlHandler) Filter() (method string, path string) { - return "GET", "/sitemap.xml" -} - -func (r *SitemapXmlHandler) IsTemplated() bool { - return false -} - -func (r *SitemapXmlHandler) TemplatesToInject() []string { - return []string{"views/pages/sitemap.xml"} -} - -func (r *SitemapXmlHandler) ToCache() router.CacheSetting { - return router.ByUrlOnly -} - -func (r *SitemapXmlHandler) CacheDuration() time.Duration { - return time.Hour -} - -func (r *SitemapXmlHandler) ToValidateLang() router.LangSetting { - return router.NotRequired -} - -func (r *SitemapXmlHandler) ContentType() string { - return fiber.MIMEApplicationXMLCharsetUTF8 -} - -func (r *SitemapXmlHandler) Render(c *fiber.Ctx, supplements *router.Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) { - urls := []router.SitemapInfo{} - for _, route := range router.Routes { - sitemapInfo := route.SitemapInfo(supplements) - if sitemapInfo == nil { - continue - } - - method, match := route.Filter() - lastMod, err := supplements.TemplateManager.GetLastModified(method + " " + match) - if err != nil { - slog.Warn("failed to get last modified for a route", - slog.String("error", err.Error()), - slog.String("method", method), - slog.String("match", match)) - lastMod = time.Now() - } - - for i := range sitemapInfo { - if sitemapInfo[i].LastModified.Before(lastMod) { - sitemapInfo[i].LastModified = lastMod - } - if sitemapInfo[i].ChangeFreq == "" { - sitemapInfo[i].ChangeFreq = "monthly" - } - urls = append(urls, sitemapInfo[i]) - } - } - - templateMap["URLs"] = urls - - return fiber.StatusOK, nil -} diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go new file mode 100644 index 0000000..bafb0d2 --- /dev/null +++ b/internal/router/lang-blog-title.go @@ -0,0 +1,82 @@ +package router + +import ( + "bytes" + "fmt" + "html/template" + "log/slog" + "slices" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" + "github.com/yuin/goldmark" +) + +func init() { + tm.Add("blog-page", "views/layouts/general-page.html", "views/pages/blog-page.html") +} + +func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + ip := c.IP() + slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path())) + + lang := c.Params("lang") + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } + + metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+c.Params("title")) + if err != nil { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title"))) + } + + geolocationParts := strings.Split(metadata.Geolocation, " ") + var x, y, areaError string + if len(geolocationParts) >= 2 { + x = geolocationParts[0] + y = geolocationParts[1] + } + if len(geolocationParts) >= 3 { + areaError = geolocationParts[2] + } + + content, err := tm.Render("blog-page", fiber.Map{ + "Title": metadata.Title, + "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"), + "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()), + "ParsedMarkdown": template.HTML(parsedMarkdown), + "MapLocationX": x, + "MapLocationY": y, + "MapLocationAreaMeters": areaError, + "Lang": lang, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Send(content) + } +} + +func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) { + metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md") + if err != nil { + return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err) + } + + var buf bytes.Buffer + if err := md.Convert(markdown, &buf); err != nil { + return nil, "", fmt.Errorf("convert source context from md to html: %w", err) + } + + return metadata, buf.String(), nil +} diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go new file mode 100644 index 0000000..e5220c6 --- /dev/null +++ b/internal/router/lang-blog.go @@ -0,0 +1,93 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "regexp" + "slices" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("blog-catalogue", "views/layouts/general-page.html", "views/pages/blog-catalogue.html") +} + +func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } + + querySort := c.Query("sort") + if querySort == "" { + querySort = "publicationDateDesc" + } + + encodedQuery := c.Request().URI().QueryString() + re, err := regexp.Compile(`tags\[\]=([\w]+)`) + if err != nil { + slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") + } + decodedQuery, _ := url.QueryUnescape(string(encodedQuery)) + matches := re.FindAllStringSubmatch(decodedQuery, -1) + + queryTags := make([]string, 0, len(matches)) + for _, match := range matches { + queryTags = append(queryTags, string(match[1])) + } + + pages, err := b2Client.Scan(lang + "/") + status := fiber.StatusOK + if err != nil { + status = fiber.StatusPartialContent + pages = []*b2.BlogPage{} + } + + tagsMap := make(map[string]int) + for _, page := range pages { + for _, tag := range page.Metadata.Tags { + tagsMap[tag]++ + } + } + slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path())) + + type Tag struct { + Name string `json:"Name" yaml:"name"` + Count int `json:"Count" yaml:"count"` + } + + tagsArray := make([]Tag, 0, len(tagsMap)) + for tag, count := range tagsMap { + tagsArray = append(tagsArray, Tag{tag, count}) + } + slices.SortFunc(tagsArray, func(a Tag, b Tag) int { + return strings.Compare(a.Name, b.Name) + }) + + content, err := tm.Render("blog-catalogue", fiber.Map{ + "QuerySort": querySort, + "QueryTags": strings.Join(queryTags, ","), + "Tags": tagsArray, + "Lang": lang, + "L": l[lang], + "PublishedYear": "2025", + "Title": l[lang].BlogSearch.Header, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Status(status).Send(content) + } +} diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go new file mode 100644 index 0000000..e94d756 --- /dev/null +++ b/internal/router/lang-map.go @@ -0,0 +1,85 @@ +package router + +import ( + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("global-map", "views/pages/global-map.html") +} + +func Lang_Map(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } + + 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/path-matcher.go b/internal/router/path-matcher.go deleted file mode 100644 index c8aef08..0000000 --- a/internal/router/path-matcher.go +++ /dev/null @@ -1,48 +0,0 @@ -package router - -import ( - "github.com/gofiber/fiber/v2" - "github.com/valyala/fasthttp" -) - -type PathMatcher struct { - app *fiber.App -} - -func NewPathMatcher() *PathMatcher { - app := fiber.New(fiber.Config{ - DisableStartupMessage: true, - }) - return &PathMatcher{app: app} -} - -func (pm *PathMatcher) AddRoute(method, pattern string) { - pm.app.Add(method, pattern, func(c *fiber.Ctx) error { - c.Locals("pattern", pattern) - return nil - }) -} - -func (pm *PathMatcher) MatchPath(method, path string) (pattern string, params map[string]string, matched bool) { - fctx := &fasthttp.RequestCtx{} - fctx.Request.Header.SetMethod(method) - fctx.Request.SetRequestURI(path) - - ctx := pm.app.AcquireCtx(fctx) - defer pm.app.ReleaseCtx(ctx) - - pm.app.Handler()(fctx) - - if ctx.Route() == nil { - return "", nil, false - } - - pattern = ctx.Locals("pattern").(string) - - params = make(map[string]string) - for _, paramName := range ctx.Route().Params { - params[paramName] = ctx.Params(paramName) - } - - return pattern, params, true -} diff --git a/internal/router/rate-limiters.go b/internal/router/rate-limiters.go deleted file mode 100644 index b43581d..0000000 --- a/internal/router/rate-limiters.go +++ /dev/null @@ -1,37 +0,0 @@ -package router - -import ( - "time" - - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/limiter" -) - -var ( - RateLimiterStrict = limiter.New(limiter.Config{ - Max: 5, - Expiration: time.Minute, - KeyGenerator: func(c *fiber.Ctx) string { return c.IP() }, - LimitReached: func(c *fiber.Ctx) error { - return c.Status(fiber.StatusTooManyRequests).SendString("rate limit") - }, - }) - - RateLimiterMedium = limiter.New(limiter.Config{ - Max: 30, - Expiration: time.Minute, - KeyGenerator: func(c *fiber.Ctx) string { return c.IP() }, - LimitReached: func(c *fiber.Ctx) error { - return c.Status(fiber.StatusTooManyRequests).SendString("rate limit") - }, - }) - - RateLimiterLoose = limiter.New(limiter.Config{ - Max: 60, - Expiration: time.Minute, - KeyGenerator: func(c *fiber.Ctx) string { return c.IP() }, - LimitReached: func(c *fiber.Ctx) error { - return c.Status(fiber.StatusTooManyRequests).SendString("rate limit") - }, - }) -) diff --git a/internal/router/root.go b/internal/router/root.go new file mode 100644 index 0000000..f4a0692 --- /dev/null +++ b/internal/router/root.go @@ -0,0 +1,27 @@ +package router + +import ( + "log/slog" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("index", "views/index.html") +} + +func Root(localeCfg []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + content, err := tm.Render("index", fiber.Map{ + "AvailableLanguages": localeCfg, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Send(content) + } +} diff --git a/internal/router/router.go b/internal/router/router.go deleted file mode 100644 index a11a934..0000000 --- a/internal/router/router.go +++ /dev/null @@ -1,699 +0,0 @@ -package router - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "html/template" - "log/slog" - "net" - "net/url" - "os" - "slices" - "strconv" - "strings" - "time" - - "github.com/SayaAndy/saya-today-web/config" - "github.com/SayaAndy/saya-today-web/internal/blog" - "github.com/SayaAndy/saya-today-web/internal/blogtrigger" - "github.com/SayaAndy/saya-today-web/internal/factgiver" - "github.com/SayaAndy/saya-today-web/internal/glightbox" - "github.com/SayaAndy/saya-today-web/internal/mailer" - "github.com/SayaAndy/saya-today-web/internal/tailwind" - "github.com/SayaAndy/saya-today-web/internal/templatemanager" - "github.com/dgraph-io/ristretto/v2" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/compress" - "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/gofiber/fiber/v2/middleware/etag" - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/sqlite3" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/parser" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/renderer/html" - "github.com/yuin/goldmark/util" -) - -type CacheSetting int - -const ( - Disabled CacheSetting = iota - ByUrlOnly - ByUrlAndQuery -) - -type LangSetting int - -const ( - NotRequired LangSetting = iota - InPath - InForm - InReferer -) - -var ( - Routes = make([]Route, 0) -) - -type SitemapInfo struct { - Loc string - LastModified time.Time - ChangeFreq string - Priority float32 -} - -type MetaField struct { - Name string - Property string - Content string -} - -type Route interface { - Filter() (method string, path string) - IsTemplated() bool - ToCache() CacheSetting - CacheDuration() time.Duration - ToValidateLang() LangSetting - TemplatesToInject() []string - SitemapInfo(supplements *Supplements) []SitemapInfo - ContentType() string - RateLimiter() *fiber.Handler - Render(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) - AddMeta(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (meta []MetaField, err error) - AddLinkedData(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (ld map[string]any, err error) - RenderHeader(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) - RenderBody(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) - RenderFooter(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) - RenderTopEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) - RenderBottomEmbeds(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (statusCode int, err error) -} - -type Supplements struct { - DB *sql.DB - BlogClient blog.Client - AvailableLanguages []config.AvailableLanguageConfig - ClientCache *ClientCache - PageCache *ristretto.Cache[string, []byte] - FactGiver *factgiver.FactGiver - Mailer *mailer.Mailer - BlogTrigger *blogtrigger.BlogTriggerScheduler - TemplateManager *templatemanager.TemplateManager - MarkdownRenderer goldmark.Markdown - Meta []config.MetaConfig - PhotoStorage config.PhotoStorageConfig - StaticStorage config.StaticStorageConfig -} - -type Router struct { - supplements *Supplements - app *fiber.App - templatedRoutes map[string]map[string]Route - templatedPathMatcher *PathMatcher - canonicalEndpoint string - endpoint config.EndpointConfig -} - -func NewRouter(cfg *config.Config) (*Router, error) { - supplements := &Supplements{ - AvailableLanguages: cfg.AvailableLanguages, - } - - var err error - - supplements.DB, err = sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN) - if err != nil { - return nil, fmt.Errorf("fail to initialize db: %w", err) - } - - driver, err := sqlite3.WithInstance(supplements.DB, &sqlite3.Config{}) - if err != nil { - return nil, fmt.Errorf("fail to initialize driver for migrating db: %w", err) - } - - m, err := migrate.NewWithDatabaseInstance( - "file://migrations", - cfg.Auth.Db.Type, driver) - if err != nil { - return nil, fmt.Errorf("fail to initialize migration client: %w", err) - } - - if err = m.Up(); err != nil && err == errors.New("no change") { - return nil, fmt.Errorf("fail to apply migrations: %w", err) - } - slog.Debug("successfully applied migrations") - - supplements.BlogClient, err = blog.NewClientMap[cfg.BlogPages.Storage.Type](&cfg.BlogPages.Storage) - if err != nil { - return nil, fmt.Errorf("fail to initialize blog client: type %s: %w", cfg.BlogPages.Storage.Type, err) - } - - supplements.MarkdownRenderer = goldmark.New( - goldmark.WithExtensions( - glightbox.NewGLightboxExtension(cfg.PhotoStorage), - tailwind.NewTailwindExtension(), - ), - goldmark.WithParserOptions( - parser.WithAutoHeadingID(), - parser.WithAttribute(), - ), - goldmark.WithRenderer( - renderer.NewRenderer( - renderer.WithNodeRenderers( - util.Prioritized(tailwind.NewCustomLinkRenderer(html.WithUnsafe(), html.WithXHTML()), 50), - util.Prioritized(html.NewRenderer(html.WithXHTML()), 100), - ), - ), - ), - ) - - supplements.ClientCache, err = NewClientCache(supplements.DB, []byte(cfg.Auth.Salt)) - if err != nil { - return nil, fmt.Errorf("fail to initialize client cache: %w", err) - } - - supplements.PageCache, err = ristretto.NewCache(&ristretto.Config[string, []byte]{ - NumCounters: 1e6, // 1,000,000 - MaxCost: 1 << 29, // 512 MB - BufferItems: 64, // number of keys per Get buffer. - }) - if err != nil { - return nil, fmt.Errorf("fail to initialize page cache: %w", err) - } - - supplements.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, supplements.AvailableLanguages) - if err != nil { - return nil, fmt.Errorf("fail to initialize fact giver: %w", err) - } - - supplements.Mailer, err = mailer.NewMailer(supplements.DB, cfg.Mail.ClientHost, cfg.Mail.MailHost, - cfg.Mail.PublicName, cfg.Mail.MailAddress, cfg.Mail.Username, cfg.Mail.Password, []byte(cfg.Mail.Salt)) - if err != nil { - return nil, fmt.Errorf("fail to initialize mailer: %w", err) - } - - supplements.BlogTrigger, err = blogtrigger.NewBlogTriggerScheduler(supplements.BlogClient, cfg.AvailableLanguages, cfg.Mail.Trigger.OnNewPost, - func(bp []*blog.Page) error { - for _, post := range bp { - if err := supplements.Mailer.NewPost(post); err != nil { - return err - } - } - return nil - }) - if err != nil { - return nil, fmt.Errorf("fail to initialize blog trigger: %w", err) - } - - supplements.TemplateManager, err = templatemanager.NewTemplateManager() - if err != nil { - return nil, fmt.Errorf("fail to initialize template manager: %w", err) - } - - supplements.Meta = cfg.Meta - supplements.PhotoStorage = cfg.PhotoStorage - supplements.StaticStorage = cfg.StaticStorage - - enablePrintRoutes := false - if cfg.LogLevel <= slog.LevelDebug { - enablePrintRoutes = true - } - - app := fiber.New(fiber.Config{ - EnablePrintRoutes: enablePrintRoutes, - ProxyHeader: "X-Forwarded-For", - }) - - app.Use(cors.New(cors.Config{ - AllowOrigins: strings.Join(cfg.AllowOrigins, ","), - AllowMethods: "GET,POST,OPTIONS", - AllowHeaders: "Origin, Content-Type, Accept", - })) - - app.Use(compress.New(compress.Config{ - Level: compress.LevelBestSpeed, - })) - - app.Use(etag.New()) - - app.Use(func(c *fiber.Ctx) error { - path := c.Path() - if len(path) > 1 && path[len(path)-1] == '/' { - return c.Redirect(strings.TrimRight(path, "/"), fiber.StatusMovedPermanently) - } - return c.Next() - }) - - templatedRoutes := make(map[string]map[string]Route) - templatedPathMatcher := NewPathMatcher() - - return &Router{supplements, app, templatedRoutes, templatedPathMatcher, cfg.CanonicalEndpoint, cfg.Endpoint}, nil -} - -func (r *Router) InitRoutes() (err error) { - r.supplements.TemplateManager.Add("general-page", "views/layouts/general-page.html") - for _, route := range Routes { - method, match := route.Filter() - - if err = r.supplements.TemplateManager.Add(method+" "+match, route.TemplatesToInject()...); err != nil { - return fmt.Errorf("failed to add '%s %s' route into template manager: %w", method, match, err) - } - - if rateLimiter := route.RateLimiter(); rateLimiter != nil { - r.app.Use(match, *rateLimiter) - } - - if route.IsTemplated() { - if _, ok := r.templatedRoutes[method]; !ok { - r.templatedRoutes[method] = make(map[string]Route) - } - r.templatedRoutes[method][match] = route - r.templatedPathMatcher.AddRoute(method, match) - - currentRoute := route - r.app.Add(method, match, func(c *fiber.Ctx) error { - lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang()) - if err != nil { - return err - } - err = r.generalPage(c, currentRoute, lang) - return err - }) - } else { - currentRoute := route - r.app.Add(method, match, func(c *fiber.Ctx) error { - lang, err := r.getAndValidateLang(c, currentRoute.ToValidateLang()) - if err != nil { - return err - } - - var cacheKey string - trimmedPath := strings.Trim(c.Path(), "/") - queryString := c.Request().URI().QueryString() - - switch currentRoute.ToCache() { - case ByUrlOnly: - cacheKey = fmt.Sprintf("%s.full-page.%s", method, trimmedPath) - case ByUrlAndQuery: - cacheKey = fmt.Sprintf("%s.full-page.%s.%s", method, trimmedPath, queryString) - case Disabled: - c.Set("Cache-Control", "no-store, no-cache, must-revalidate") - } - - defaultMap := fiber.Map{ - "Lang": lang, - "Path": trimmedPath, - "QueryString": queryString, - "CanonicalEndpoint": r.canonicalEndpoint, - "StaticStorage": r.supplements.StaticStorage, - "PhotoStorage": r.supplements.PhotoStorage, - } - - statusCode, err := currentRoute.Render(c, r.supplements, lang, defaultMap) - method := c.Method() - _, match := currentRoute.Filter() - if err != nil { - slog.Error("failed to finish rendering a page", - slog.Int("status_code", statusCode), - slog.String("method", method), - slog.String("path", c.Path()), - slog.String("match", match), - slog.String("query", string(queryString)), - slog.String("error", err.Error()), - ) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(statusCode).SendString(err.Error()) - } - - content, err := r.supplements.TemplateManager.Render(method+" "+match, defaultMap) - if err != nil { - slog.Error("failed to generate div", - slog.String("method", method), - slog.String("path", c.Path()), - slog.String("match", match), - slog.String("query", string(queryString)), - slog.String("error", err.Error()), - ) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") - } - if val, ok := defaultMap["Output"]; ok && len(content) == 0 { - content = val.([]byte) - } - - if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled { - go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) - } - - c.Set(fiber.HeaderContentType, route.ContentType()) - return c.Status(statusCode).Send(content) - }) - } - } - - segments := []string{"header", "body", "footer", "top-embeds", "bottom-embeds"} - - r.app.Get("/api/v1/general-page/:part", func(c *fiber.Ctx) error { - part := c.Params("part") - if !slices.Contains(segments, part) { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("unknown segment '%s'", part)) - } - c.Set("Cache-Control", "no-store, no-cache, must-revalidate") - err := r.generalPageSegment(c, part) - return err - }) - - for _, segment := range segments { - r.supplements.TemplateManager.Add("general-page-"+segment, "views/partials/general-page-"+segment+".html") - } - - r.app.Static("/", "./static") - - // Routes = make([]Route, 0) - - return nil -} - -func (r *Router) Listen() error { - switch r.endpoint.Type { - case "unix": - unixConfig := r.endpoint.Config.(*config.UnixConfig) - endpoint, _ := strings.CutPrefix(unixConfig.Path, "unix://") - - if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("error while cleaning up existing unix socket: %w", err) - } - - ln, err := net.Listen("unix", endpoint) - if err != nil { - return fmt.Errorf("error while initializing unix listener: %w", err) - } - chmod, _ := strconv.ParseUint(unixConfig.Chmod[1:], 8, 32) - os.Chmod(unixConfig.Path, os.FileMode(chmod)) - if err := r.app.Listener(ln); err != nil { - return fmt.Errorf("error while running fiber server: %w", err) - } - case "http": - httpConfig := r.endpoint.Config.(*config.HttpConfig) - fmt.Print(httpConfig) - if err := r.app.Listen(httpConfig.ListenOn); err != nil { - return fmt.Errorf("error while running fiber server: %w", err) - } - default: - return fmt.Errorf("error with initializing fiber server: invalid endpoint type (supported are unix and http)") - } - - return nil -} - -func (r *Router) Close() (err error) { - allErrors := make([]error, 0) - slog.Debug("shutting down blog trigger scheduler") - if err = r.supplements.BlogTrigger.Close(); err != nil { - allErrors = append(allErrors, fmt.Errorf("fail to shutdown blog trigger scheduler: %w", err)) - } - slog.Debug("shutting down fiber server") - if err = r.app.Shutdown(); err != nil { - allErrors = append(allErrors, fmt.Errorf("fail to shutdown fiber server: %w", err)) - } - slog.Debug("dumping cache") - if err = r.supplements.ClientCache.Close(); err != nil { - allErrors = append(allErrors, fmt.Errorf("fail to dump cache: %w", err)) - } - slog.Debug("closing db connection") - if err = r.supplements.DB.Close(); err != nil { - allErrors = append(allErrors, fmt.Errorf("fail to close db connection: %w", err)) - } - slog.Debug("closing page cache") - r.supplements.PageCache.Close() - - return errors.Join(allErrors...) -} - -func (r *Router) generalPage(c *fiber.Ctx, route Route, lang string) error { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - - path := c.Path() - method := c.Method() - trimmedPath := strings.Trim(path, "/") - queryString := c.Request().URI().QueryString() - cacheKey := "" - - switch route.ToCache() { - case ByUrlOnly: - cacheKey = fmt.Sprintf("%s.general-page.%s", method, trimmedPath) - case ByUrlAndQuery: - cacheKey = fmt.Sprintf("%s.general-page.%s.%s", method, trimmedPath, queryString) - } - - if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok { - c.Set(fiber.HeaderContentType, route.ContentType()) - return c.Status(fiber.StatusOK).Send(val) - } - - valueMap := fiber.Map{ - "Lang": lang, - "Path": trimmedPath, - "QueryString": queryString, - "CanonicalEndpoint": r.canonicalEndpoint, - "StaticStorage": r.supplements.StaticStorage, - "PhotoStorage": r.supplements.PhotoStorage, - } - - var err error - if valueMap["Meta"], err = route.AddMeta(c, r.supplements, lang, valueMap); err != nil { - slog.Warn("failed to receive meta", slog.String("path", path), slog.String("error", err.Error())) - } - - var ld map[string]any - if ld, err = route.AddLinkedData(c, r.supplements, lang, valueMap); err != nil { - slog.Warn("failed to receive linked data", slog.String("path", path), slog.String("error", err.Error())) - } - if ld != nil { - ldBytes, _ := json.Marshal(ld) - valueMap["LinkedData"] = template.JS(ldBytes) - } - - syntheticReferer := path - if len(queryString) > 0 { - syntheticReferer += "?" + string(queryString) - } - c.Request().Header.Set("Referer", syntheticReferer) - - parts := []struct { - name string - key string - render func(c *fiber.Ctx, supplements *Supplements, lang string, templateMap fiber.Map) (int, error) - }{ - {"top-embeds", "RenderedTopEmbeds", route.RenderTopEmbeds}, - {"header", "RenderedHeader", route.RenderHeader}, - {"body", "RenderedBody", route.RenderBody}, - {"footer", "RenderedFooter", route.RenderFooter}, - {"bottom-embeds", "RenderedBottomEmbeds", route.RenderBottomEmbeds}, - } - - for _, p := range parts { - statusCode, err := p.render(c, r.supplements, lang, valueMap) - if err != nil { - slog.Error("failed to render segment for full page", - slog.String("path", path), - slog.String("segment", p.name), - slog.String("error", err.Error()), - ) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(statusCode).SendString(err.Error()) - } - segContent, err := r.supplements.TemplateManager.Render("general-page-"+p.name, valueMap, route.TemplatesToInject()...) - if err != nil { - slog.Error("failed to render segment template for full page", - slog.String("path", path), - slog.String("segment", p.name), - slog.String("error", err.Error()), - ) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate segment") - } - valueMap[p.key] = template.HTML(segContent) - } - - content, err := r.supplements.TemplateManager.Render("general-page", valueMap) - if err != nil { - slog.Warn("failed to generate full page", slog.String("path", path), slog.String("error", err.Error())) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate full page") - } - - go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) - c.Set(fiber.HeaderContentType, route.ContentType()) - return c.Status(fiber.StatusOK).Send(content) -} - -func (r *Router) generalPageSegment(c *fiber.Ctx, part string) error { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - - path, pathParts, queryString, err := GetPathFromReferer(c) - if err != nil { - return err - } - - method := c.Method() - pattern, _, matched := r.templatedPathMatcher.MatchPath(method, path) - if !matched { - return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("fail to get templated page for '%s %s': not found", method, path)) - } - route := r.templatedRoutes[method][pattern] - - lang := "" - - switch route.ToValidateLang() { - case InPath, InReferer: - if len(pathParts) > 0 { - lang = pathParts[0] - } - if _, err := r.getAndValidateLang(c, NotRequired, lang); err != nil { - return err - } - case InForm: - if lang, err = r.getAndValidateLang(c, InForm); err != nil { - return err - } - } - - cacheKey := "" - trimmedPath := strings.Trim(path, "/") - requestQuery := string(c.Request().URI().QueryString()) - switch route.ToCache() { - case ByUrlOnly: - cacheKey = fmt.Sprintf("%s.%s.%s", method, part, trimmedPath) - case ByUrlAndQuery: - cacheKey = fmt.Sprintf("%s.%s.%s.%s.%s", method, part, trimmedPath, queryString, requestQuery) - } - - if route.ToCache() != Disabled { - if val, ok := r.supplements.PageCache.Get(cacheKey); val != nil && ok { - c.Set(fiber.HeaderContentType, route.ContentType()) - return c.Status(fiber.StatusOK).Send(val) - } - } - - var statusCode int - defaultMap := fiber.Map{ - "Lang": lang, - "Path": strings.Trim(path, "/"), - "QueryString": queryString, - "CanonicalEndpoint": r.canonicalEndpoint, - "StaticStorage": r.supplements.StaticStorage, - "PhotoStorage": r.supplements.PhotoStorage, - } - - switch part { - case "body": - statusCode, err = route.RenderBody(c, r.supplements, lang, defaultMap) - case "header": - statusCode, err = route.RenderHeader(c, r.supplements, lang, defaultMap) - case "footer": - statusCode, err = route.RenderFooter(c, r.supplements, lang, defaultMap) - case "top-embeds": - statusCode, err = route.RenderTopEmbeds(c, r.supplements, lang, defaultMap) - case "bottom-embeds": - statusCode, err = route.RenderBottomEmbeds(c, r.supplements, lang, defaultMap) - } - if err != nil { - slog.Error(err.Error(), - slog.String("method", method), - slog.String("path", path), - slog.String("query", string(queryString)), - slog.String("segment", part), - slog.String("error", err.Error()), - ) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(statusCode).SendString(err.Error()) - } - - content, err := r.supplements.TemplateManager.Render("general-page-"+part, defaultMap, route.TemplatesToInject()...) - if err != nil { - slog.Error("failed to generate div", - slog.String("method", method), - slog.String("path", path), - slog.String("query", string(queryString)), - slog.String("segment", part), - slog.String("error", err.Error()), - ) - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") - } - - if statusCode == fiber.StatusNoContent && len(content) != 0 { - statusCode = fiber.StatusOK - } - - if statusCode >= 200 && statusCode < 300 && route.ToCache() != Disabled { - go r.supplements.PageCache.SetWithTTL(cacheKey, content, int64(len(content)), route.CacheDuration()) - } - - c.Set(fiber.HeaderContentType, route.ContentType()) - return c.Status(statusCode).Send(content) -} - -func (r *Router) getAndValidateLang(c *fiber.Ctx, langSetting LangSetting, defaultLang ...string) (string, error) { - var lang string - if len(defaultLang) > 0 { - lang = defaultLang[0] - } - - switch langSetting { - case NotRequired: - return lang, nil - - case InPath: - path := c.Path() - - pathParts := strings.Split(strings.Trim(path, "/"), "/") - if len(pathParts) == 1 && pathParts[0] == "" { - pathParts = []string{} - } - if len(pathParts) > 0 && len(pathParts[0]) == 2 { - lang = pathParts[0] - } - - case InForm: - lang = c.FormValue("lang") - - case InReferer: - _, pathParts, _, err := GetPathFromReferer(c) - if err != nil { - return "", err - } - - if len(pathParts) >= 1 { - lang = pathParts[0] - } - } - - for _, availableLang := range r.supplements.AvailableLanguages { - if availableLang.Name == lang { - return lang, nil - } - } - return "", fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("lang value is invalid: '%s' is not considered an available language", lang)) -} - -func GetPathFromReferer(c *fiber.Ctx) (path string, pathParts []string, queryString string, err error) { - referer := c.Get("Referer") - if referer == "" { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") - } - urlStruct, err := url.ParseRequestURI(referer) - if err != nil { - c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) - return "", nil, "", c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) - } - - path = urlStruct.EscapedPath() - pathParts = strings.Split(strings.Trim(path, "/"), "/") - queryString = urlStruct.RawQuery - return -} diff --git a/internal/router/tm.go b/internal/router/tm.go new file mode 100644 index 0000000..e8095ee --- /dev/null +++ b/internal/router/tm.go @@ -0,0 +1,18 @@ +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/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 index f90489f..2f052b9 100644 --- a/internal/tailwind/transformer.go +++ b/internal/tailwind/transformer.go @@ -1,9 +1,6 @@ package tailwind import ( - "bytes" - "fmt" - "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" @@ -20,53 +17,49 @@ func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader, switch node := n.(type) { case *ast.Heading: classes := map[int]string{ - 1: "font-gentium text-4xl font-bold text-main-hard mb-3 tracking-[.0125rem]", - 2: "font-gentium text-3xl font-bold text-main-hard mb-1 tracking-[.0125rem]", - 3: "font-gentium text-2xl font-medium text-main-hard mb-0.8 tracking-[.0125rem]", - 4: "font-gentium text-xl font-medium text-main-medium mb-0.5 tracking-[.0125rem]", - 5: "font-gentium text-base font-medium text-main-medium mb-0.5 italic tracking-[.0125rem]", - 6: "font-gentium text-base font-medium text-secondary mb-0.5", + 1: "font-patua text-[2.5vmax] font-bold text-main-dark mb-[1.2vmin] tracking-[0.04vmin]", + 2: "font-spectral text-[2vmax] font-bold text-main-dark mb-[0.8vmin] tracking-[0.04vmin]", + 3: "font-spectral text-[1.5vmax] font-medium text-main-dark mb-[0.6vmin] tracking-[0.04vmin]", + 4: "font-spectral text-[1.2vmax] font-medium text-main-medium mb-[0.4vmin] tracking-[0.04vmin]", + 5: "font-spectral text-[1vmax] font-medium text-main-medium mb-[0.4vmin] italic tracking-[0.04vmin]", + 6: "font-spectral text-[1vmax] font-medium text-secondary mb-[0.4vmin]", } 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-gentium tracking-[.0125rem] -indent-8 ml-4 mb-8")) + node.SetAttribute([]byte("class"), []byte("text-[1vmax]/[2] font-spectral tracking-[0.04vmin] -indent-[2vmax] ml-[2vmax] mb-[1.6vmin]")) case *ast.List: if node.IsOrdered() { - node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-2 mb-4 pl-8")) + node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-[0.8vmin] mb-[1.6vmin] pl-[2vmax]")) } else { - node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-2 mb-4 pl-8")) + node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-[0.8vmin] mb-[1.6vmin] pl-[2vmax]")) } case *ast.ListItem: - node.SetAttribute([]byte("class"), []byte("text-base/[2] font-gentium tracking-[.0125rem]")) + node.SetAttribute([]byte("class"), []byte("text-[1vmax]/[2] font-spectral tracking-[0.04vmin]")) case *ast.Blockquote: - node.SetAttribute([]byte("class"), []byte("border-l-[0.125rem] border-main-medium bg-paper bg-background-dark p-1 mb-2 italic font-thin")) + node.SetAttribute([]byte("class"), []byte("border-l-[0.4vmin] border-main-medium bg-background-dark p-[0.8vmin] mb-[0.8vmin] italic")) case *ast.CodeSpan: - node.SetAttribute([]byte("class"), []byte("bg-paper bg-background-dark")) + node.SetAttribute([]byte("class"), []byte("bg-background-dark")) case *ast.CodeBlock, *ast.FencedCodeBlock: - node.SetAttribute([]byte("class"), []byte("bg-paper bg-background-dark p-1 rounded-lg overflow-x-auto mb-4")) + node.SetAttribute([]byte("class"), []byte("bg-background-dark p-[0.8vmin] rounded-lg overflow-x-auto mb-[1.6vmin]")) 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")) + node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-dark underline")) case *ast.Image: - node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-4")) + node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-[1.6vmin]")) case *ast.Emphasis: switch node.Level { case 1: - node.SetAttribute([]byte("class"), []byte("italic font-thin")) + node.SetAttribute([]byte("class"), []byte("italic")) case 2: node.SetAttribute([]byte("class"), []byte("font-bold")) case 3: diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go index ff1214a..f89d0c8 100644 --- a/internal/templatemanager/templatemanager.go +++ b/internal/templatemanager/templatemanager.go @@ -4,12 +4,8 @@ import ( "bytes" "fmt" "html/template" - "os" "path/filepath" "strings" - "time" - - "github.com/SayaAndy/saya-today-web/l10n" ) type TemplateManager struct { @@ -17,9 +13,8 @@ type TemplateManager struct { } type templateManagerRender struct { - Main string - Tmpl *template.Template - LastModified time.Time + Main string + Tmpl *template.Template } type TemplateManagerTemplates struct { @@ -27,50 +22,20 @@ type TemplateManagerTemplates struct { Files []string } -var templateFuncMap = template.FuncMap{ - "contains": strings.Contains, - "iterate": func(count uint) []uint { - items := make([]uint, count) - for i := range count { - items[i] = i - } - return items - }, - "replace": strings.ReplaceAll, - "fdiv": func(a, b int) float64 { - return float64(a) / float64(b) - }, - "l": func(path ...any) any { - return l10n.T.GetPath(path...) - }, - "join": strings.Join, -} - func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) { templateMap := make(map[string]templateManagerRender) for _, tmplStruct := range templates { - tmpl := template.New(tmplStruct.Name).Funcs(templateFuncMap) - if len(tmplStruct.Files) == 0 { - templateMap[tmplStruct.Name] = templateManagerRender{ - Main: "", - Tmpl: tmpl, - } - continue - } + tmpl := template.New("").Funcs(template.FuncMap{ + "contains": strings.Contains, + }) tmpl, err := tmpl.ParseFiles(tmplStruct.Files...) if err != nil { return nil, err } - modTime, err := setLastModified(tmplStruct.Files...) - if err != nil { - return nil, err - } - templateMap[tmplStruct.Name] = templateManagerRender{ - Main: filepath.Base(tmplStruct.Files[0]), - Tmpl: tmpl, - LastModified: modTime, + Main: filepath.Base(tmplStruct.Files[0]), + Tmpl: tmpl, } } @@ -79,89 +44,34 @@ func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager }, nil } -func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byte, error) { +func (tm *TemplateManager) Render(name string, data interface{}) ([]byte, error) { tmpl, exists := tm.templates[name] if !exists { - return nil, fmt.Errorf("template %s not found", name) - } - - var err error - var tempTmpl *template.Template - var mainTmpl string = tmpl.Main - if len(files) == 0 { - if mainTmpl == "" { - return []byte{}, nil - } - 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) - } - if mainTmpl == "" { - mainTmpl = filepath.Base(files[0]) - } + return nil, fmt.Errorf("template %s is not found", name) } var buf bytes.Buffer - err = tempTmpl.ExecuteTemplate(&buf, mainTmpl, data) + err := tmpl.Tmpl.ExecuteTemplate(&buf, tmpl.Main, data) return buf.Bytes(), err } func (tm *TemplateManager) Add(name string, files ...string) error { - tmpl := template.New(name).Funcs(templateFuncMap) - if len(files) == 0 { - tm.templates[name] = templateManagerRender{ - Main: "", - Tmpl: tmpl, - } - return nil + return fmt.Errorf("you can't add template without any files") } - tmpl, err := tmpl.ParseFiles(files...) - if err != nil { - return fmt.Errorf("failed to add template into manager: %w", err) - } + tmpl := template.New("").Funcs(template.FuncMap{ + "contains": strings.Contains, + }) - modTime, err := setLastModified(files...) + 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, - LastModified: modTime, + Main: filepath.Base(files[0]), + Tmpl: tmpl, } - return nil } - -func (tm *TemplateManager) GetLastModified(name string) (time.Time, error) { - tmpl, exists := tm.templates[name] - if !exists { - return time.Time{}, fmt.Errorf("template %s not found", name) - } - - return tmpl.LastModified, nil -} - -func setLastModified(filenames ...string) (time.Time, error) { - lastModified := time.Time{} - for _, filename := range filenames { - stat, err := os.Stat(filename) - if err != nil { - return lastModified, fmt.Errorf("failed to stat file: path '%s': %w", filename, err) - } - modTime := stat.ModTime() - if lastModified.Before(modTime) { - lastModified = modTime - } - } - return lastModified, nil -} |