Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 377 |
1 files changed, 281 insertions, 96 deletions
@@ -1,32 +1,32 @@ package main import ( - "database/sql" - "errors" + "bytes" "flag" + "fmt" + "html/template" + "log" "log/slog" + "net/url" "os" - "os/signal" - "syscall" + "regexp" + "slices" + "strconv" + "strings" + "time" "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/internal/b2" - "github.com/SayaAndy/saya-today-web/internal/factgiver" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" "github.com/SayaAndy/saya-today-web/internal/lightgallery" - "github.com/SayaAndy/saya-today-web/internal/router" "github.com/SayaAndy/saya-today-web/internal/tailwind" + "github.com/SayaAndy/saya-today-web/internal/templatemanager" "github.com/SayaAndy/saya-today-web/locale" - "github.com/dgraph-io/ristretto/v2" "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cors" - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/sqlite3" + "github.com/gofiber/fiber/v2/middleware/redirect" "github.com/yuin/goldmark" "github.com/yuin/goldmark/parser" gmhtml "github.com/yuin/goldmark/renderer/html" - - _ "github.com/golang-migrate/migrate/v4/source/file" - _ "github.com/mattn/go-sqlite3" ) var ( @@ -62,32 +62,6 @@ func main() { slog.SetLogLoggerLevel(cfg.LogLevel) slog.Info("starting sayana-web server...") - db, err := sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN) - if err != nil { - slog.Error("fail to initialize db", slog.String("error", err.Error())) - os.Exit(1) - } - - driver, err := sqlite3.WithInstance(db, &sqlite3.Config{}) - if err != nil { - slog.Error("fail to initialize driver for migrating db", slog.String("error", err.Error())) - os.Exit(1) - } - - m, err := migrate.NewWithDatabaseInstance( - "file://migrations", - cfg.Auth.Db.Type, driver) - if err != nil { - slog.Error("fail to initialize migration client", slog.String("error", err.Error())) - os.Exit(1) - } - - if err = m.Up(); err != nil && err == errors.New("no change") { - slog.Error("fail to apply migrations", slog.String("error", err.Error())) - os.Exit(1) - } - slog.Info("successfully applied migrations") - b2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config) if err != nil { slog.Error("fail to initialize b2 client", slog.String("error", err.Error())) @@ -104,77 +78,288 @@ func main() { localization[lang.Name] = localeCfg } - app := fiber.New(fiber.Config{ - ProxyHeader: "X-Forwarded-For", + tm, err := templatemanager.NewTemplateManager([]templatemanager.TemplateManagerTemplates{ + {Name: "blog-page", Files: []string{"views/layouts/general-page.html", "views/pages/blog-page.html"}}, + {Name: "blog-catalogue", Files: []string{"views/layouts/general-page.html", "views/pages/blog-catalogue.html"}}, + {Name: "index", Files: []string{"views/index.html"}}, + {Name: "catalogue-blog-cards", Files: []string{"views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"}}, }) - - app.Use(cors.New(cors.Config{ - AllowOrigins: "https://f003.backblazeb2.com", - AllowMethods: "GET,POST,OPTIONS", - AllowHeaders: "Origin, Content-Type, Accept", - })) - - router.CCache, err = router.NewClientCache(db, []byte(cfg.Auth.Salt)) if err != nil { - slog.Error("fail to initialize client cache", slog.String("error", err.Error())) + slog.Error("fail to initialize template manager", slog.String("error", err.Error())) os.Exit(1) } - router.PCache, 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. + app := fiber.New(fiber.Config{}) + + app.Use(redirect.New(redirect.Config{ + Rules: map[string]string{ + "/ru": "/", + "/en": "/", + }, + StatusCode: 301, + })) + + app.Get("/", func(c *fiber.Ctx) error { + content, err := tm.Render("index", fiber.Map{ + "AvailableLanguages": cfg.AvailableLanguages, + }) + 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) }) - if err != nil { - slog.Error("fail to initialize page cache", slog.String("error", err.Error())) - os.Exit(1) - } - router.FactGiver, err = factgiver.NewFactGiver(&cfg.FactGiver, availableLanguages) - if err != nil { - slog.Error("fail to initialize fact giver", slog.String("error", err.Error())) - os.Exit(1) - } + app.Get("/:lang/blog", func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(availableLanguages, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } - app.Get("/", router.Root(cfg.AvailableLanguages)) - app.Get("/:lang<len(2)>", router.Api_V1_GeneralPage(localization, availableLanguages)) - app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client)) - app.Get("/:lang/blog", router.Api_V1_GeneralPage(localization, availableLanguages)) - app.Get("/:lang/blog/:title", router.Api_V1_GeneralPage(localization, availableLanguages)) + querySort := c.Query("sort") + if querySort == "" { + querySort = "publicationDateDesc" + } - app.Get("/api/v1/tz", router.Api_V1_TZ()) - app.Get("/api/v1/blog-search", router.Api_V1_BlogSearch(localization, availableLanguages, b2Client)) - app.Get("/api/v1/like", router.Api_V1_Like_Get(localization, b2Client)) - app.Put("/api/v1/like", router.Api_V1_Like_Put(localization, b2Client)) - app.Get("/api/v1/general-page/header", router.Api_V1_GeneralPage_Header(localization, availableLanguages, b2Client)) - app.Get("/api/v1/general-page/body", router.Api_V1_GeneralPage_Body(localization, availableLanguages, b2Client, md)) - app.Get("/api/v1/general-page/footer", router.Api_V1_GeneralPage_Footer(localization, availableLanguages)) - app.Get("/api/v1/general-page/top-embeds", router.Api_V1_GeneralPage_TopEmbeds(localization, availableLanguages)) - app.Get("/api/v1/general-page/bottom-embeds", router.Api_V1_GeneralPage_BottomEmbeds(localization, availableLanguages, b2Client)) + 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, err := url.QueryUnescape(string(encodedQuery)) + matches := re.FindAllStringSubmatch(decodedQuery, -1) - app.Static("/", "./static") + 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{} + } - go func() { - if err := app.Listen(":3000"); err != nil { - slog.Error("error while running fiber server", slog.String("error", err.Error())) - panic(err) + tagsMap := make(map[string]int) + for _, page := range pages { + slog.Debug("enlist page for catalogue", slog.Any("page", page), slog.String("endpoint", "/"+lang+"/blog")) + for _, tag := range page.Metadata.Tags { + tagsMap[tag]++ + } } - }() - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + type Tag struct { + Name string `json:"Name" yaml:"name"` + Count int `json:"Count" yaml:"count"` + } - <-sigChan - slog.Info("gracefully shutting down...") - if err = app.Shutdown(); err != nil { - slog.Error("fail to shutdown fiber server", slog.String("error", err.Error())) - } - if err = router.CCache.Close(); err != nil { - slog.Error("fail to dump cache", slog.String("error", err.Error())) + 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": localization[lang], + "PublishedYear": "2025", + "Title": localization[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) + }) + + app.Get("/:lang/blog/:title", func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(availableLanguages, 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(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) + }) + + app.Get("/api/v1/tz", 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)) + }) + + app.Get("/api/v1/blog-search", func(c *fiber.Ctx) error { + sort := c.Query("sort") + lang := c.Query("lang") + tz := c.Query("tz") + + if !slices.Contains(availableLanguages, 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, err := 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": localization[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) + }) + + app.Static("/", "./static") + + log.Fatal(app.Listen(":3000")) +} + +func readBlogPost(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) } - if err = db.Close(); err != nil { - slog.Error("fail to close db connection", slog.String("error", err.Error())) + + 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) } - router.PCache.Close() - db.Close() + + return metadata, buf.String(), nil } |